diff --git a/WordPress/build.gradle b/WordPress/build.gradle index 5d2535b7266d..ffb8fa889646 100644 --- a/WordPress/build.gradle +++ b/WordPress/build.gradle @@ -151,6 +151,8 @@ android { buildConfigField "boolean", "READER_DISCOVER_NEW_ENDPOINT", "false" buildConfigField "boolean", "READER_READING_PREFERENCES", "false" buildConfigField "boolean", "READER_READING_PREFERENCES_FEEDBACK", "false" + buildConfigField "boolean", "READER_TAGS_FEED", "false" + buildConfigField "boolean", "READER_ANNOUNCEMENT_CARD", "false" buildConfigField "boolean", "VOICE_TO_CONTENT", "false" // Override these constants in jetpack product flavor to enable/ disable features diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/ReaderPostTableWrapper.kt b/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/ReaderPostTableWrapper.kt index eeabbd6a0e0f..8b532688a530 100644 --- a/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/ReaderPostTableWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/ReaderPostTableWrapper.kt @@ -5,6 +5,8 @@ import org.wordpress.android.datasets.ReaderPostTable import org.wordpress.android.models.ReaderPost import org.wordpress.android.models.ReaderPostList import org.wordpress.android.models.ReaderTag +import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateResult +import org.wordpress.android.ui.reader.models.ReaderBlogIdPostId import javax.inject.Inject @Reusable @@ -30,6 +32,23 @@ class ReaderPostTableWrapper @Inject constructor() { fun getNumPostsWithTag(readerTag: ReaderTag): Int = ReaderPostTable.getNumPostsWithTag(readerTag) - fun addOrUpdatePosts(readerTag: ReaderTag, posts: ReaderPostList) = + fun addOrUpdatePosts(readerTag: ReaderTag?, posts: ReaderPostList) = ReaderPostTable.addOrUpdatePosts(readerTag, posts) + + fun deletePostsWithTag(tag: ReaderTag) = ReaderPostTable.deletePostsWithTag(tag) + + fun comparePosts(posts: ReaderPostList): UpdateResult = ReaderPostTable.comparePosts(posts) + + fun updateBookmarkedPostPseudoId(posts: ReaderPostList) = ReaderPostTable.updateBookmarkedPostPseudoId(posts) + + fun setGapMarkerForTag(blogId: Long, postId: Long, tag: ReaderTag) = + ReaderPostTable.setGapMarkerForTag(blogId, postId, tag) + + fun removeGapMarkerForTag(tag: ReaderTag) = ReaderPostTable.removeGapMarkerForTag(tag) + + fun deletePostsBeforeGapMarkerForTag(tag: ReaderTag) = ReaderPostTable.deletePostsBeforeGapMarkerForTag(tag) + + fun hasOverlap(posts: ReaderPostList?, tag: ReaderTag): Boolean = ReaderPostTable.hasOverlap(posts, tag) + + fun getGapMarkerIdsForTag(tag: ReaderTag): ReaderBlogIdPostId? = ReaderPostTable.getGapMarkerIdsForTag(tag) } diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderTag.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderTag.java index a8e8018e9814..4676fb991291 100644 --- a/WordPress/src/main/java/org/wordpress/android/models/ReaderTag.java +++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderTag.java @@ -184,6 +184,10 @@ public boolean isBookmarked() { return tagType == ReaderTagType.BOOKMARKED; } + public boolean isTags() { + return tagType == ReaderTagType.TAGS; + } + public boolean isDiscover() { return tagType == ReaderTagType.DEFAULT && getEndpoint().endsWith(DISCOVER_PATH); } @@ -204,7 +208,7 @@ public boolean isA8C() { } public boolean isFilterable() { - return this.isFollowedSites() || this.isA8C() || this.isP2(); + return this.isFollowedSites() || this.isA8C() || this.isP2() || this.isTags(); } public boolean isListTopic() { diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderTagType.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderTagType.java index d7d84f191168..cb3486efc523 100644 --- a/WordPress/src/main/java/org/wordpress/android/models/ReaderTagType.java +++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderTagType.java @@ -7,7 +7,8 @@ public enum ReaderTagType { CUSTOM_LIST, SEARCH, INTERESTS, - DISCOVER_POST_CARDS; + DISCOVER_POST_CARDS, + TAGS; private static final int INT_DEFAULT = 0; private static final int INT_FOLLOWED = 1; diff --git a/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProvider.kt b/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProvider.kt index 9bac2972def3..2fcff83c7a75 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProvider.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProvider.kt @@ -2,7 +2,7 @@ package org.wordpress.android.ui.bloggingprompts import org.wordpress.android.models.ReaderTag import org.wordpress.android.models.ReaderTagType -import org.wordpress.android.ui.reader.services.post.ReaderPostLogic +import org.wordpress.android.ui.reader.repository.ReaderPostRepository import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper import javax.inject.Inject @@ -23,7 +23,7 @@ class BloggingPromptsPostTagProvider @Inject constructor( promptIdTag, promptIdTag, promptIdTag, - ReaderPostLogic.formatFullEndpointForTag(promptIdTag), + ReaderPostRepository.formatFullEndpointForTag(promptIdTag), ReaderTagType.FOLLOWED, ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/promptslist/compose/BloggingPromptsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/promptslist/compose/BloggingPromptsListScreen.kt index e499fad2f4b2..a809a3bdc48c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/promptslist/compose/BloggingPromptsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/promptslist/compose/BloggingPromptsListScreen.kt @@ -112,8 +112,8 @@ private fun FetchErrorContent() { @Composable private fun NetworkErrorContent() { EmptyContent( - title = stringResource(R.string.blogging_prompts_list_error_network_title), - subtitle = stringResource(R.string.blogging_prompts_list_error_network_subtitle), + title = stringResource(R.string.no_connection_error_title), + subtitle = stringResource(R.string.no_connection_error_description), image = R.drawable.img_illustration_cloud_off_152dp, modifier = Modifier.fillMaxSize(), ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java index 5bf4bdb7824b..e8f7950d4568 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java @@ -204,6 +204,7 @@ public enum DeletablePrefKey implements PrefKey { SHOULD_HIDE_DYNAMIC_CARD, PINNED_SITE_IDS, READER_READING_PREFERENCES_JSON, + SHOULD_SHOW_READER_ANNOUNCEMENT_CARD, } /** @@ -1784,6 +1785,14 @@ public static void setPinnedSiteLocalIds(@NonNull final String ids) { setString(DeletablePrefKey.PINNED_SITE_IDS, ids); } + public static boolean getShouldShowReaderAnnouncementCard() { + return prefs().getBoolean(DeletablePrefKey.SHOULD_SHOW_READER_ANNOUNCEMENT_CARD.name(), true); + } + + public static void setShouldShowReaderAnnouncementCard(final boolean shouldShow) { + prefs().edit().putBoolean(DeletablePrefKey.SHOULD_SHOW_READER_ANNOUNCEMENT_CARD.name(), shouldShow).apply(); + } + @Nullable public static String getReaderReadingPreferencesJson() { return getString(DeletablePrefKey.READER_READING_PREFERENCES_JSON, null); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt index 4a835daca578..35748c160639 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt @@ -450,6 +450,15 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra fun getShouldHideDynamicCard(id: String, ): Boolean = AppPrefs.getShouldHideDynamicCard(id) + fun shouldUpdateBookmarkPostsPseudoIds(tag: ReaderTag?): Boolean = AppPrefs.shouldUpdateBookmarkPostsPseudoIds(tag) + + fun setBookmarkPostsPseudoIdsUpdated() = AppPrefs.setBookmarkPostsPseudoIdsUpdated() + + fun shouldShowReaderAnnouncementCard(): Boolean = AppPrefs.getShouldShowReaderAnnouncementCard() + + fun setShouldShowReaderAnnouncementCard(shouldShow: Boolean) = + AppPrefs.setShouldShowReaderAnnouncementCard(shouldShow) + fun getAllPrefs(): Map = AppPrefs.getAllPrefs() fun getDebugBooleanPref(key: String, default: Boolean = false) = diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java index 32b2261e2527..d8167a10c791 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java @@ -175,11 +175,19 @@ public static void showReaderTagPreview(Context context, @NonNull ReaderTag tag, tag.getTagSlug(), source ); - Intent intent = new Intent(context, ReaderPostListActivity.class); + final Intent intent = createReaderTagPreviewIntent(context, tag, source); + context.startActivity(intent); + } + + @NonNull + public static Intent createReaderTagPreviewIntent(@NonNull final Context context, + @NonNull final ReaderTag tag, + @NonNull final String source) { + final Intent intent = new Intent(context, ReaderPostListActivity.class); intent.putExtra(ReaderConstants.ARG_SOURCE, source); intent.putExtra(ReaderConstants.ARG_TAG, tag); intent.putExtra(ReaderConstants.ARG_POST_LIST_TYPE, ReaderPostListType.TAG_PREVIEW); - context.startActivity(intent); + return intent; } public static void showReaderSearch(Context context) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt index 23e1b6835c44..ec1427374780 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt @@ -13,7 +13,9 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import org.greenrobot.eventbus.EventBus @@ -22,23 +24,34 @@ import org.greenrobot.eventbus.ThreadMode.MAIN import org.wordpress.android.R import org.wordpress.android.databinding.ReaderFragmentLayoutBinding import org.wordpress.android.models.JetpackPoweredScreen +import org.wordpress.android.models.ReaderTag import org.wordpress.android.ui.ScrollableViewInitializedListener import org.wordpress.android.ui.compose.theme.AppTheme import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureFullScreenOverlayFragment import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalOverlayUtil.JetpackFeatureOverlayScreenType -import org.wordpress.android.ui.main.WPMainActivity import org.wordpress.android.ui.main.WPMainActivity.OnScrollToTopListener import org.wordpress.android.ui.main.WPMainNavigationView.PageType.READER import org.wordpress.android.ui.mysite.jetpackbadge.JetpackPoweredBottomSheetFragment import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.quickstart.QuickStartEvent +import org.wordpress.android.ui.reader.SubfilterBottomSheetFragment.Companion.newInstance import org.wordpress.android.ui.reader.discover.ReaderDiscoverFragment import org.wordpress.android.ui.reader.discover.interests.ReaderInterestsFragment +import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask.FOLLOWED_BLOGS import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask.TAGS import org.wordpress.android.ui.reader.services.update.ReaderUpdateServiceStarter +import org.wordpress.android.ui.reader.subfilter.ActionType +import org.wordpress.android.ui.reader.subfilter.ActionType.OpenLoginPage +import org.wordpress.android.ui.reader.subfilter.ActionType.OpenSearchPage +import org.wordpress.android.ui.reader.subfilter.ActionType.OpenSubsAtPage +import org.wordpress.android.ui.reader.subfilter.ActionType.OpenSuggestedTagsPage +import org.wordpress.android.ui.reader.subfilter.BottomSheetUiState +import org.wordpress.android.ui.reader.subfilter.BottomSheetUiState.BottomSheetVisible import org.wordpress.android.ui.reader.subfilter.SubFilterViewModel +import org.wordpress.android.ui.reader.subfilter.SubFilterViewModelProvider import org.wordpress.android.ui.reader.subfilter.SubfilterCategory +import org.wordpress.android.ui.reader.subfilter.SubfilterListItem import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel.ReaderUiState.ContentUiState import org.wordpress.android.ui.reader.views.compose.ReaderTopAppBar @@ -46,18 +59,21 @@ import org.wordpress.android.ui.reader.views.compose.filter.ReaderFilterType import org.wordpress.android.ui.utils.UiHelpers import org.wordpress.android.ui.utils.UiString.UiStringText import org.wordpress.android.util.JetpackBrandingUtils +import org.wordpress.android.util.NetworkUtils import org.wordpress.android.util.QuickStartUtilsWrapper import org.wordpress.android.util.SnackbarItem import org.wordpress.android.util.SnackbarItem.Action import org.wordpress.android.util.SnackbarItem.Info import org.wordpress.android.util.SnackbarSequencer +import org.wordpress.android.viewmodel.Event +import org.wordpress.android.viewmodel.main.WPMainActivityViewModel import org.wordpress.android.viewmodel.observeEvent import java.util.EnumSet import javax.inject.Inject @AndroidEntryPoint class ReaderFragment : Fragment(R.layout.reader_fragment_layout), ScrollableViewInitializedListener, - WPMainActivity.OnScrollToTopListener { + OnScrollToTopListener, SubFilterViewModelProvider { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory @@ -72,12 +88,95 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), ScrollableView @Inject lateinit var snackbarSequencer: SnackbarSequencer + private lateinit var viewModel: ReaderViewModel private var binding: ReaderFragmentLayoutBinding? = null private var readerSearchResultLauncher: ActivityResultLauncher? = null + private var readerSubsActivityResultLauncher: ActivityResultLauncher? = null + + private val wpMainActivityViewModel by lazy { + ViewModelProvider( + requireActivity(), + viewModelFactory + )[WPMainActivityViewModel::class.java] + } + + // region SubgroupFilterViewModel Observers + // we need a reference to the observers so they are properly handled by the lifecycle and ViewModel owners, avoiding + // duplication, and ensuring they are properly removed when the Fragment is destroyed + private val currentSubfilterObserver = Observer { subfilterListItem -> + viewModel.onSubFilterItemSelected(subfilterListItem) + } + + private val updateTagsAndSitesObserver = Observer>> { event -> + event.applyIfNotHandled { + if (NetworkUtils.isNetworkAvailable(activity)) { + ReaderUpdateServiceStarter.startService(activity, this) + } + } + } + + private val subFiltersObserver = Observer> { subFilters -> + val selectedTag = (viewModel.uiState.value as? ContentUiState)?.selectedReaderTag ?: return@Observer + viewModel.showTopBarFilterGroup( + selectedTag, + subFilters + ) + } + + private val bottomSheetUiStateObserver = Observer> { event -> + event.applyIfNotHandled { + val selectedTag = (viewModel.uiState.value as? ContentUiState)?.selectedReaderTag + ?: return@applyIfNotHandled + val viewModelKey = SubFilterViewModel.getViewModelKeyForTag(selectedTag) + + val fm = childFragmentManager + var bottomSheet = fm.findFragmentByTag(SUBFILTER_BOTTOM_SHEET_TAG) as SubfilterBottomSheetFragment? + if (isVisible && bottomSheet == null) { + val (title, category) = this as BottomSheetVisible + bottomSheet = newInstance( + viewModelKey, + category, + uiHelpers.getTextOfUiString(requireContext(), title) + ) + bottomSheet.show(childFragmentManager, SUBFILTER_BOTTOM_SHEET_TAG) + } else if (!isVisible && bottomSheet != null) { + bottomSheet.dismiss() + } + } + } + + private val bottomSheetActionObserver = Observer> { event -> + event.applyIfNotHandled { + when (this) { + is OpenSubsAtPage -> { + readerSubsActivityResultLauncher?.launch( + ReaderActivityLauncher.createIntentShowReaderSubs( + requireActivity(), + tabIndex + ) + ) + } + + is OpenLoginPage -> { + wpMainActivityViewModel.onOpenLoginPage() + } + + is OpenSearchPage -> { + ReaderActivityLauncher.showReaderSearch(requireActivity()) + } + + is OpenSuggestedTagsPage -> { + ReaderActivityLauncher.showReaderInterests(requireActivity()) + } + } + } + } + // endregion + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding = ReaderFragmentLayoutBinding.bind(view).apply { initTopAppBar() @@ -103,6 +202,7 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), ScrollableView override fun onAttach(context: Context) { super.onAttach(context) initReaderSearchActivityResultLauncher() + initReaderSubsActivityResultLauncher() } private fun initReaderSearchActivityResultLauncher() { @@ -122,6 +222,25 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), ScrollableView } } + private fun initReaderSubsActivityResultLauncher() { + readerSubsActivityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + val data = result.data + if (data != null) { + val shouldRefreshSubscriptions = data.getBooleanExtra( + ReaderSubsActivity.RESULT_SHOULD_REFRESH_SUBSCRIPTIONS, + false + ) + if (shouldRefreshSubscriptions) { + getSubFilterViewModel()?.loadSubFilters() + } + } + } + } + } + private fun ReaderFragmentLayoutBinding.initTopAppBar() { readerTopBarComposeView.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -144,7 +263,7 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), ScrollableView } private fun ReaderFragmentLayoutBinding.initViewModel(savedInstanceState: Bundle?) { - viewModel = ViewModelProvider(this@ReaderFragment, viewModelFactory).get(ReaderViewModel::class.java) + viewModel = ViewModelProvider(this@ReaderFragment, viewModelFactory)[ReaderViewModel::class.java] startReaderViewModel(savedInstanceState) } @@ -226,14 +345,15 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), ScrollableView } childFragmentManager.beginTransaction().apply { - val fragment = if (uiState.selectedReaderTag.isDiscover) { - ReaderDiscoverFragment() - } else { - ReaderPostListFragment.newInstanceForTag( - uiState.selectedReaderTag, + val selectedTag = uiState.selectedReaderTag + val fragment = when { + selectedTag.isDiscover -> ReaderDiscoverFragment() + selectedTag.isTags -> ReaderTagsFeedFragment.newInstance(selectedTag) + else -> ReaderPostListFragment.newInstanceForTag( + selectedTag, ReaderTypes.ReaderPostListType.TAG_FOLLOWED, true, - uiState.selectedReaderTag.isFilterable + selectedTag.isFilterable ) } replace(R.id.container, fragment, uiState.selectedReaderTag.tagSlug) @@ -352,19 +472,6 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), ScrollableView return childFragmentManager.findFragmentById(R.id.container) } - // The view model is started by the ReaderPostListFragment for feeds that support filtering - private fun getSubFilterViewModel(): SubFilterViewModel? { - val currentFragment = getCurrentFeedFragment() - val selectedTag = (viewModel.uiState.value as? ContentUiState)?.selectedReaderTag - - if (currentFragment == null || selectedTag == null) return null - - return ViewModelProvider(currentFragment, viewModelFactory).get( - SubFilterViewModel.getViewModelKeyForTag(selectedTag), - SubFilterViewModel::class.java - ) - } - private fun tryOpenFilterList(type: ReaderFilterType) { val viewModel = getSubFilterViewModel() ?: return @@ -389,4 +496,80 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), ScrollableView currentFragment.onScrollToTop() } } + + /** + * The owner of the SubFilterViewModel should be the current feed Fragment, so it can be properly cleared when the + * feed is changed, since it will be properly tied to the expected feed Fragment lifecycle instead of the + * [ReaderFragment] lifecycle. + * + * This method exists mainly for readability purposes and to avoid passing the Fragment as a parameter. + * + * Note: it can cause a crash if the current feed Fragment is not available for any reason, which should never + * happen since the calling methods are always called by the feed Fragment or their children. + */ + private fun getSubFilterViewModelOwner(): ViewModelStoreOwner { + return getCurrentFeedFragment() as ViewModelStoreOwner + } + + private fun getSubFilterViewModel(): SubFilterViewModel? { + val selectedTag = (viewModel.uiState.value as? ContentUiState)?.selectedReaderTag ?: return null + return getSubFilterViewModelForTag(selectedTag) + } + + /** + * Get the SubFilterViewModel for the given key. It doesn't initialize the ViewModel if it's not already started, so + * should only be used for getting a ViewModel that's already been started. + */ + override fun getSubFilterViewModelForKey(key: String): SubFilterViewModel { + return ViewModelProvider(getSubFilterViewModelOwner(), viewModelFactory)[key, SubFilterViewModel::class.java] + } + + override fun getSubFilterViewModelForTag(tag: ReaderTag, savedInstanceState: Bundle?): SubFilterViewModel { + return ViewModelProvider(getSubFilterViewModelOwner(), viewModelFactory)[ + SubFilterViewModel.getViewModelKeyForTag(tag), + SubFilterViewModel::class.java + ].also { + it.initSubFilterViewModel(tag, savedInstanceState) + } + } + + private fun SubFilterViewModel.initSubFilterViewModel(startedTag: ReaderTag, savedInstanceState: Bundle?) { + bottomSheetUiState.observe( + viewLifecycleOwner, + bottomSheetUiStateObserver + ) + + bottomSheetAction.observe( + viewLifecycleOwner, + bottomSheetActionObserver + ) + + currentSubFilter.observe( + viewLifecycleOwner, + currentSubfilterObserver + ) + + + updateTagsAndSites.observe( + viewLifecycleOwner, + updateTagsAndSitesObserver + ) + + if (startedTag.isFilterable) { + subFilters.observe( + viewLifecycleOwner, + subFiltersObserver + ) + + updateTagsAndSites() + } else { + viewModel.hideTopBarFilterGroup(startedTag) + } + + start(startedTag, startedTag, savedInstanceState) + } + + companion object { + private const val SUBFILTER_BOTTOM_SHEET_TAG = "SUBFILTER_BOTTOM_SHEET_TAG" + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java index 6b2e3aa4ba6c..98665420c1e2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java @@ -29,7 +29,6 @@ import androidx.core.content.ContextCompat; import androidx.core.text.HtmlCompat; import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.FragmentManager; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.RecyclerView; @@ -110,12 +109,8 @@ import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask; import org.wordpress.android.ui.reader.services.update.ReaderUpdateServiceStarter; import org.wordpress.android.ui.reader.services.update.TagUpdateClientUtilsProvider; -import org.wordpress.android.ui.reader.subfilter.ActionType.OpenLoginPage; -import org.wordpress.android.ui.reader.subfilter.ActionType.OpenSearchPage; -import org.wordpress.android.ui.reader.subfilter.ActionType.OpenSubsAtPage; -import org.wordpress.android.ui.reader.subfilter.ActionType.OpenSuggestedTagsPage; -import org.wordpress.android.ui.reader.subfilter.BottomSheetUiState.BottomSheetVisible; import org.wordpress.android.ui.reader.subfilter.SubFilterViewModel; +import org.wordpress.android.ui.reader.subfilter.SubFilterViewModelProvider; import org.wordpress.android.ui.reader.subfilter.SubfilterListItem.Site; import org.wordpress.android.ui.reader.subfilter.SubfilterListItem.SiteAll; import org.wordpress.android.ui.reader.tracker.ReaderTracker; @@ -145,7 +140,6 @@ import org.wordpress.android.util.config.ReaderImprovementsFeatureConfig; import org.wordpress.android.util.config.SeenUnseenWithCounterFeatureConfig; import org.wordpress.android.util.image.ImageManager; -import org.wordpress.android.viewmodel.main.WPMainActivityViewModel; import org.wordpress.android.widgets.AppRatingDialog; import org.wordpress.android.widgets.RecyclerItemDecoration; import org.wordpress.android.widgets.WPSnackbar; @@ -607,24 +601,15 @@ private void addWebViewCachingFragment(Long blogId, Long postId) { } private void initSubFilterViewModel(@Nullable Bundle savedInstanceState) { - WPMainActivityViewModel wpMainActivityViewModel = new ViewModelProvider(requireActivity(), mViewModelFactory) - .get(WPMainActivityViewModel.class); - - mSubFilterViewModel = new ViewModelProvider(this, mViewModelFactory).get( - SubFilterViewModel.getViewModelKeyForTag(mTagFragmentStartedWith), - SubFilterViewModel.class - ); + mSubFilterViewModel = SubFilterViewModelProvider. + getSubFilterViewModelForTag(this, mTagFragmentStartedWith, savedInstanceState); mSubFilterViewModel.getCurrentSubFilter().observe(getViewLifecycleOwner(), subfilterListItem -> { if (getPostListType() != ReaderPostListType.SEARCH_RESULTS) { - mSubFilterViewModel.onSubfilterSelected(subfilterListItem); - if (shouldShowEmptyViewForSelfHostedCta()) { setEmptyTitleDescriptionAndButton(false); showEmptyView(); } - - if (mReaderViewModel != null) mReaderViewModel.onSubFilterItemSelected(subfilterListItem); } }); @@ -633,70 +618,6 @@ private void initSubFilterViewModel(@Nullable Bundle savedInstanceState) { changeReaderMode(readerModeInfo, true); } }); - - mSubFilterViewModel.getBottomSheetUiState().observe(getViewLifecycleOwner(), event -> { - event.applyIfNotHandled(uiState -> { - FragmentManager fm = getChildFragmentManager(); - if (fm != null) { - SubfilterBottomSheetFragment bottomSheet = - (SubfilterBottomSheetFragment) fm.findFragmentByTag(SUBFILTER_BOTTOM_SHEET_TAG); - if (uiState.isVisible() && bottomSheet == null) { - mSubFilterViewModel.loadSubFilters(); - BottomSheetVisible visibleState = (BottomSheetVisible) uiState; - bottomSheet = SubfilterBottomSheetFragment.newInstance( - SubFilterViewModel.getViewModelKeyForTag(mTagFragmentStartedWith), - visibleState.getCategory(), - mUiHelpers.getTextOfUiString(requireContext(), visibleState.getTitle()) - ); - bottomSheet.show(getChildFragmentManager(), SUBFILTER_BOTTOM_SHEET_TAG); - } else if (!uiState.isVisible() && bottomSheet != null) { - bottomSheet.dismiss(); - } - } - return null; - }); - }); - - mSubFilterViewModel.getBottomSheetAction().observe(getViewLifecycleOwner(), event -> { - event.applyIfNotHandled(action -> { - if (action instanceof OpenSubsAtPage) { - mReaderSubsActivityResultLauncher.launch( - ReaderActivityLauncher.createIntentShowReaderSubs( - requireActivity(), - ((OpenSubsAtPage) action).getTabIndex() - ) - ); - } else if (action instanceof OpenLoginPage) { - wpMainActivityViewModel.onOpenLoginPage(); - } else if (action instanceof OpenSearchPage) { - ReaderActivityLauncher.showReaderSearch(requireActivity()); - } else if (action instanceof OpenSuggestedTagsPage) { - ReaderActivityLauncher.showReaderInterests(requireActivity()); - } - - return null; - }); - }); - - mSubFilterViewModel.getUpdateTagsAndSites().observe(getViewLifecycleOwner(), event -> { - event.applyIfNotHandled(tasks -> { - if (NetworkUtils.isNetworkAvailable(getActivity())) { - ReaderUpdateServiceStarter.startService(getActivity(), tasks); - } - return null; - }); - }); - - if (mIsFilterableScreen) { - mSubFilterViewModel.getSubFilters().observe(getViewLifecycleOwner(), subFilters -> { - mReaderViewModel.showTopBarFilterGroup(mTagFragmentStartedWith, subFilters); - }); - mSubFilterViewModel.updateTagsAndSites(); - } else { - mReaderViewModel.hideTopBarFilterGroup(mTagFragmentStartedWith); - } - - mSubFilterViewModel.start(mTagFragmentStartedWith, mCurrentTag, savedInstanceState); } private void changeReaderMode(ReaderModeInfo readerModeInfo, boolean onlyOnChanges) { @@ -858,6 +779,13 @@ public void onAttach(@NonNull Context context) { } initReaderSubsActivityResultLauncher(); + + final Activity activity = getActivity(); + if (activity != null) { + final Intent intent = new Intent(); + intent.putExtra(ReaderTagsFeedFragment.RESULT_SHOULD_REFRESH_TAGS_FEED, true); + activity.setResult(Activity.RESULT_OK, intent); + } } private void initReaderSubsActivityResultLauncher() { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagsFeedFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagsFeedFragment.kt new file mode 100644 index 000000000000..531c9742fc38 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagsFeedFragment.kt @@ -0,0 +1,400 @@ +package org.wordpress.android.ui.reader + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Gravity +import android.view.View +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.ListPopupWindow +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.core.view.ViewCompat.animate +import androidx.core.view.isVisible +import androidx.fragment.app.commitNow +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.R +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.databinding.ReaderTagFeedFragmentLayoutBinding +import org.wordpress.android.models.ReaderTag +import org.wordpress.android.ui.ViewPagerFragment +import org.wordpress.android.ui.compose.theme.AppThemeWithoutBackground +import org.wordpress.android.ui.main.WPMainActivity +import org.wordpress.android.ui.reader.adapters.ReaderMenuAdapter +import org.wordpress.android.ui.reader.discover.ReaderNavigationEvents +import org.wordpress.android.ui.reader.discover.interests.ReaderInterestsFragment +import org.wordpress.android.ui.reader.subfilter.SubFilterViewModel +import org.wordpress.android.ui.reader.subfilter.SubFilterViewModelProvider +import org.wordpress.android.ui.reader.subfilter.SubfilterListItem +import org.wordpress.android.ui.reader.tracker.ReaderTracker +import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper +import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel +import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel.ActionEvent +import org.wordpress.android.ui.reader.views.compose.tagsfeed.ReaderTagsFeed +import org.wordpress.android.ui.utils.UiHelpers +import org.wordpress.android.util.extensions.getSerializableCompat +import org.wordpress.android.viewmodel.observeEvent +import org.wordpress.android.widgets.WPSnackbar +import javax.inject.Inject + +/** + * Initial implementation of ReaderTagsFeedFragment with the idea of it containing both a ComposeView, which will host + * all Compose content related to the new Tags Feed as well as an internal ReaderPostListFragment, which will be used + * to display "filtered" content based on the currently selected tag on the top app bar filter. + * + * It might be tricky to get this working properly since a lot of places expect the ReaderPostListFragment to be the + * main content of the ReaderFragment (e.g.: initializing the SubFilterViewModel), so a few changes might be needed. + */ +@AndroidEntryPoint +class ReaderTagsFeedFragment : ViewPagerFragment(R.layout.reader_tag_feed_fragment_layout), + WPMainActivity.OnScrollToTopListener { + private val tagsFeedTag by lazy { + // TODO maybe we can just create a static function somewhere that returns the Tags Feed ReaderTag, since it's + // used in multiple places, client-side only, and always the same. + requireArguments().getSerializableCompat(ARG_TAGS_FEED_TAG)!! + } + + @Inject + lateinit var viewModelFactory: ViewModelProvider.Factory + private lateinit var subFilterViewModel: SubFilterViewModel + + private val viewModel: ReaderTagsFeedViewModel by viewModels() + + @Inject + lateinit var readerUtilsWrapper: ReaderUtilsWrapper + + @Inject + lateinit var readerTracker: ReaderTracker + + @Inject + lateinit var uiHelpers: UiHelpers + + // binding + private lateinit var binding: ReaderTagFeedFragmentLayoutBinding + + private var bookmarksSavedLocallyDialog: AlertDialog? = null + + private var readerPostListActivityResultLauncher: ActivityResultLauncher? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding = ReaderTagFeedFragmentLayoutBinding.bind(view) + + binding.composeView.setContent { + AppThemeWithoutBackground { + val uiState by viewModel.uiStateFlow.collectAsState() + ReaderTagsFeed(uiState) + } + } + observeSubFilterViewModel(savedInstanceState) + observeActionEvents() + observeNavigationEvents() + observeErrorMessageEvents() + observeSnackbarEvents() + observeOpenMoreMenuEvents() + viewModel.onViewCreated() + } + + override fun onDestroy() { + super.onDestroy() + bookmarksSavedLocallyDialog?.dismiss() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + initReaderPostListActivityResultLauncher() + } + + private fun observeSubFilterViewModel(savedInstanceState: Bundle?) { + subFilterViewModel = SubFilterViewModelProvider.getSubFilterViewModelForTag( + this, + tagsFeedTag, + savedInstanceState + ) + + // TODO not triggered when there's no internet, so the error/no connection UI is not shown. + subFilterViewModel.subFilters.observe(viewLifecycleOwner) { subFilters -> + val tags = subFilters.filterIsInstance().map { it.tag } + viewModel.onTagsChanged(tags) + } + + subFilterViewModel.currentSubFilter.observe(viewLifecycleOwner) { subFilter -> + if (subFilter is SubfilterListItem.Tag) { + showTagPostList(subFilter.tag) + } else { + hideTagPostList() + } + } + } + + private fun observeActionEvents() { + viewModel.actionEvents.observe(viewLifecycleOwner) { + when (it) { + is ActionEvent.FilterTagPostsFeed -> { + subFilterViewModel.setSubfilterFromTag(it.readerTag) + } + + is ActionEvent.OpenTagPostList -> { + if (!isAdded) { + return@observe + } + readerTracker.trackTag( + Stat.READER_TAG_PREVIEWED, + it.readerTag.tagSlug, + ReaderTracker.SOURCE_TAGS_FEED + ) + readerPostListActivityResultLauncher?.launch( + ReaderActivityLauncher.createReaderTagPreviewIntent( + requireActivity(), it.readerTag, ReaderTracker.SOURCE_TAGS_FEED + ) + ) + } + + ActionEvent.RefreshTags -> { + subFilterViewModel.updateTagsAndSites() + } + + ActionEvent.ShowTagsList -> { + val readerInterestsFragment = childFragmentManager.findFragmentByTag(ReaderInterestsFragment.TAG) + if (readerInterestsFragment == null) { + (parentFragment as? ReaderFragment)?.childFragmentManager?.beginTransaction()?.replace( + R.id.interests_fragment_container, + ReaderInterestsFragment(), + ReaderInterestsFragment.TAG + )?.commitNow() + } + } + } + } + } + + private fun showTagPostList(tag: ReaderTag) { + startPostListFragment(tag) + binding.postListContainer.fadeIn( + withEndAction = { binding.composeView.isVisible = false }, + ) + } + + private fun hideTagPostList() { + binding.composeView.isVisible = true + binding.postListContainer.fadeOut( + withEndAction = { removeCurrentPostListFragment() }, + ) + } + + private fun startPostListFragment(tag: ReaderTag) { + val tagPostListFragment = ReaderPostListFragment.newInstanceForTag( + tag, + ReaderTypes.ReaderPostListType.TAG_FOLLOWED + ) + + childFragmentManager.commitNow { + replace(R.id.post_list_container, tagPostListFragment) + } + } + + private fun removeCurrentPostListFragment() { + childFragmentManager.run { + findFragmentById(R.id.post_list_container)?.let { + commitNow { + remove(it) + } + } + } + } + + private fun View.fadeIn( + withEndAction: (() -> Unit)? = null + ) { + alpha = 0f + isVisible = true + + animate(this) + // add quick delay to give time for the fragment to be added and load some content + .setStartDelay(POST_LIST_FADE_IN_DELAY) + .setDuration(POST_LIST_FADE_DURATION) + .withEndAction { withEndAction?.invoke() } + .alpha(1f) + } + + private fun View.fadeOut( + withEndAction: (() -> Unit)? = null, + ) { + animate(this) + .withEndAction { + isVisible = false + alpha = 1f + withEndAction?.invoke() + } + .setDuration(POST_LIST_FADE_DURATION) + .alpha(0f) + } + + @Suppress("LongMethod") + private fun observeNavigationEvents() { + viewModel.navigationEvents.observeEvent(viewLifecycleOwner) { event -> + when (event) { + is ReaderNavigationEvents.ShowPostDetail -> ReaderActivityLauncher.showReaderPostDetail( + context, + event.post.blogId, + event.post.postId + ) + + is ReaderNavigationEvents.SharePost -> ReaderActivityLauncher.sharePost(context, event.post) + is ReaderNavigationEvents.OpenPost -> ReaderActivityLauncher.openPost(context, event.post) + is ReaderNavigationEvents.ShowBookmarkedSavedOnlyLocallyDialog -> { + showBookmarkSavedLocallyDialog(event) + } + + is ReaderNavigationEvents.ShowBlogPreview -> ReaderActivityLauncher.showReaderBlogOrFeedPreview( + context, + event.siteId, + event.feedId, + event.isFollowed, + ReaderTracker.SOURCE_TAGS_FEED, + readerTracker + ) + + is ReaderNavigationEvents.ShowReportPost -> ReaderActivityLauncher.openUrl( + context, + readerUtilsWrapper.getReportPostUrl(event.url), + ReaderActivityLauncher.OpenUrlType.INTERNAL + ) + + is ReaderNavigationEvents.ShowReportUser -> ReaderActivityLauncher.openUrl( + context, + readerUtilsWrapper.getReportUserUrl(event.url, event.authorId), + ReaderActivityLauncher.OpenUrlType.INTERNAL + ) + + else -> Unit // Do Nothing + } + } + } + + private fun observeErrorMessageEvents() { + viewModel.errorMessageEvents.observeEvent(viewLifecycleOwner) { stringRes -> + if (isAdded) { + WPSnackbar.make(binding.root, getString(stringRes), Snackbar.LENGTH_LONG).show() + } + } + } + + private fun observeSnackbarEvents() { + viewModel.snackbarEvents.observeEvent(viewLifecycleOwner) { snackbarMessageHolder -> + if (isAdded) { + with(snackbarMessageHolder) { + val snackbar = WPSnackbar.make( + binding.root, + uiHelpers.getTextOfUiString(requireContext(), message), + Snackbar.LENGTH_LONG + ) + if (buttonTitle != null) { + snackbar.setAction(uiHelpers.getTextOfUiString(requireContext(), buttonTitle)) { + buttonAction.invoke() + } + } + snackbar.show() + } + } + } + } + + private fun observeOpenMoreMenuEvents() { + viewModel.openMoreMenuEvents.observe(viewLifecycleOwner) { + val readerCardUiState = it.readerCardUiState + val blogId = readerCardUiState.blogId + val postId = readerCardUiState.postId + val anchorView = binding.composeView.findViewWithTag("$blogId$postId") + if (anchorView != null) { + readerTracker.track(AnalyticsTracker.Stat.POST_CARD_MORE_TAPPED) + val listPopup = ListPopupWindow(anchorView.context) + listPopup.width = anchorView.context.resources.getDimensionPixelSize(R.dimen.menu_item_width) + listPopup.setAdapter(ReaderMenuAdapter(anchorView.context, uiHelpers, it.readerPostCardActions)) + listPopup.setDropDownGravity(Gravity.END) + listPopup.anchorView = anchorView + listPopup.isModal = true + listPopup.setOnItemClickListener { _, _, position, _ -> + listPopup.dismiss() + val item = it.readerPostCardActions[position] + item.onClicked?.invoke(postId, blogId, item.type) + } + listPopup.setOnDismissListener { readerCardUiState.onMoreDismissed.invoke(readerCardUiState) } + listPopup.show() + } + } + } + + private fun showBookmarkSavedLocallyDialog( + bookmarkDialog: ReaderNavigationEvents.ShowBookmarkedSavedOnlyLocallyDialog + ) { + // TODO show bookmark saved dialog? + bookmarkDialog.buttonLabel + if (bookmarksSavedLocallyDialog == null) { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(getString(bookmarkDialog.title)) + .setMessage(getString(bookmarkDialog.message)) + .setPositiveButton(getString(bookmarkDialog.buttonLabel)) { _, _ -> + bookmarkDialog.okButtonAction.invoke() + } + .setOnDismissListener { + bookmarksSavedLocallyDialog = null + } + .setCancelable(false) + .create() + .let { + bookmarksSavedLocallyDialog = it + it.show() + } + } + } + + private fun initReaderPostListActivityResultLauncher() { + readerPostListActivityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + val data = result.data + if (data != null) { + val shouldRefreshTagsFeed = data.getBooleanExtra(RESULT_SHOULD_REFRESH_TAGS_FEED, false) + if (shouldRefreshTagsFeed) { + viewModel.onBackFromTagDetails() + } + } + } + } + } + + override fun getScrollableViewForUniqueIdProvision(): View { + return binding.composeView + } + + override fun onScrollToTop() { + // TODO scroll current content to top + } + + companion object { + const val RESULT_SHOULD_REFRESH_TAGS_FEED = "RESULT_SHOULD_REFRESH_TAGS_FEED" + + private const val ARG_TAGS_FEED_TAG = "tags_feed_tag" + private const val POST_LIST_FADE_DURATION = 250L + private const val POST_LIST_FADE_IN_DELAY = 300L + + fun newInstance( + feedTag: ReaderTag + ): ReaderTagsFeedFragment = ReaderTagsFeedFragment().apply { + arguments = Bundle().apply { + putSerializable(ARG_TAGS_FEED_TAG, feedTag) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTypes.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTypes.java index 28376be451dd..93aefb553cc2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTypes.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTypes.java @@ -9,7 +9,8 @@ public enum ReaderPostListType { TAG_FOLLOWED(ReaderTracker.SOURCE_FOLLOWING), // list posts in a followed tag TAG_PREVIEW(ReaderTracker.SOURCE_TAG_PREVIEW), // list posts in a specific tag BLOG_PREVIEW(ReaderTracker.SOURCE_SITE_PREVIEW), // list posts in a specific blog/feed - SEARCH_RESULTS(ReaderTracker.SOURCE_SEARCH); // list posts matching a specific search keyword or phrase + SEARCH_RESULTS(ReaderTracker.SOURCE_SEARCH), // list posts matching a specific search keyword or phrase + TAGS_FEED(ReaderTracker.SOURCE_TAGS_FEED); // list posts in the tags feed private final String mSource; diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/SubfilterBottomSheetFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/SubfilterBottomSheetFragment.kt index 0aa90d286fbd..262c6f2d119d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/SubfilterBottomSheetFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/SubfilterBottomSheetFragment.kt @@ -10,7 +10,6 @@ import android.widget.FrameLayout import android.widget.TextView import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStoreOwner import androidx.viewpager.widget.ViewPager import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog @@ -19,6 +18,7 @@ import org.wordpress.android.R import org.wordpress.android.WordPress import org.wordpress.android.ui.reader.subfilter.ActionType import org.wordpress.android.ui.reader.subfilter.SubFilterViewModel +import org.wordpress.android.ui.reader.subfilter.SubFilterViewModelProvider import org.wordpress.android.ui.reader.subfilter.SubfilterCategory import org.wordpress.android.ui.reader.subfilter.SubfilterCategory.SITES import org.wordpress.android.ui.reader.subfilter.SubfilterCategory.TAGS @@ -75,10 +75,7 @@ class SubfilterBottomSheetFragment : BottomSheetDialogFragment() { return } - viewModel = ViewModelProvider( - parentFragment as ViewModelStoreOwner, - viewModelFactory - )[subfilterVmKey, SubFilterViewModel::class.java] + viewModel = SubFilterViewModelProvider.getSubFilterViewModelForKey(this, subfilterVmKey) // TODO remove the pager and support only one category val pager = view.findViewById(R.id.view_pager) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderPostAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderPostAdapter.java index 5ae4c5d68fa7..af30a157d08c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderPostAdapter.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderPostAdapter.java @@ -46,9 +46,11 @@ import org.wordpress.android.ui.reader.discover.viewholders.ReaderPostNewViewHolder; import org.wordpress.android.ui.reader.discover.viewholders.ReaderPostViewHolder; import org.wordpress.android.ui.reader.models.ReaderBlogIdPostId; +import org.wordpress.android.ui.reader.utils.ReaderAnnouncementHelper; import org.wordpress.android.ui.reader.tracker.ReaderTab; import org.wordpress.android.ui.reader.tracker.ReaderTracker; import org.wordpress.android.ui.reader.utils.ReaderXPostUtils; +import org.wordpress.android.ui.reader.views.ReaderAnnouncementCardView; import org.wordpress.android.ui.reader.views.ReaderGapMarkerView; import org.wordpress.android.ui.reader.views.ReaderSiteHeaderView; import org.wordpress.android.ui.reader.views.ReaderTagHeaderView; @@ -117,9 +119,11 @@ public class ReaderPostAdapter extends RecyclerView.Adapter { + mReaderAnnouncementHelper.dismissReaderAnnouncement(); + notifyItemRemoved(getAnnouncementPosition()); + }); } } @@ -680,6 +716,12 @@ private boolean hasTagHeader() { return (getPostListType() == ReaderPostListType.TAG_PREVIEW) && !isEmpty(); } + private boolean hasAnnouncement() { + return mIsMainReader && mReaderAnnouncementHelper.hasReaderAnnouncement() && !isEmpty() + && (getPostListType() != ReaderPostListType.BLOG_PREVIEW) + && (mCurrentTag != null && !mCurrentTag.isTagTopic()); + } + private boolean isDiscover() { return mCurrentTag != null && mCurrentTag.isDiscover(); } @@ -766,6 +808,9 @@ private void loadPosts() { } private ReaderPost getItem(int position) { + if (position == getAnnouncementPosition() && hasAnnouncement()) { + return null; + } if (position == getHeaderPosition() && hasHeader()) { return null; } @@ -788,22 +833,27 @@ private ReaderPost getItem(int position) { } private int getItemPositionOffset() { - return hasHeader() ? 1 : 0; + int offset = 0; + if (hasAnnouncement()) offset++; + if (hasHeader()) offset++; + return offset; } private int getHeaderPosition() { - return hasHeader() ? 0 : -1; + int headerPosition = hasAnnouncement() ? 1 : 0; + return hasHeader() ? headerPosition : -1; + } + + private int getAnnouncementPosition() { + return hasAnnouncement() ? 0 : -1; } @Override public int getItemCount() { int size = mPosts.size(); - if (mGapMarkerPosition != -1) { - size++; - } - if (hasHeader()) { - size++; - } + if (mGapMarkerPosition != -1) size++; + if (hasHeader()) size++; + if (hasAnnouncement()) size++; return size; } @@ -824,6 +874,8 @@ public long getItemId(int position) { return ITEM_ID_HEADER; case VIEW_TYPE_GAP_MARKER: return ITEM_ID_GAP_MARKER; + case VIEW_TYPE_READER_ANNOUNCEMENT: + return ITEM_ID_READER_ANNOUNCEMENT; default: ReaderPost post = getItem(position); return post != null ? post.getStableId() : 0; @@ -892,7 +944,7 @@ protected void onCancelled() { @Override protected Boolean doInBackground(Void... params) { - int numExisting; + int numExisting = 0; switch (getPostListType()) { case TAG_PREVIEW: case TAG_FOLLOWED: @@ -909,7 +961,7 @@ protected Boolean doInBackground(Void... params) { numExisting = ReaderPostTable.getNumPostsInBlog(mCurrentBlogId); } break; - default: + case TAGS_FEED: return false; } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderCardUiState.kt index 5a1d8c33d812..6549e88cfe85 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderCardUiState.kt @@ -9,6 +9,7 @@ import org.wordpress.android.ui.reader.discover.ReaderPostCardAction.PrimaryActi import org.wordpress.android.ui.reader.discover.ReaderPostCardActionType.SPACER_NO_ACTION import org.wordpress.android.ui.reader.discover.interests.TagUiState import org.wordpress.android.ui.reader.models.ReaderImageList +import org.wordpress.android.ui.reader.views.compose.ReaderAnnouncementCardItemData import org.wordpress.android.ui.reader.views.uistates.ReaderBlogSectionUiState import org.wordpress.android.ui.utils.UiDimen import org.wordpress.android.ui.utils.UiString @@ -175,6 +176,11 @@ sealed class ReaderCardUiState { } } } + + data class ReaderAnnouncementCardUiState( + val items: List, + val onDoneClick: () -> Unit, + ) : ReaderCardUiState() } data class ReaderPostActions( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverAdapter.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverAdapter.kt index a94e2b2b1507..c6a5f2d409ec 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverAdapter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverAdapter.kt @@ -3,10 +3,12 @@ package org.wordpress.android.ui.reader.discover import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView.Adapter +import org.wordpress.android.ui.reader.discover.ReaderCardUiState.ReaderAnnouncementCardUiState import org.wordpress.android.ui.reader.discover.ReaderCardUiState.ReaderInterestsCardUiState import org.wordpress.android.ui.reader.discover.ReaderCardUiState.ReaderPostNewUiState import org.wordpress.android.ui.reader.discover.ReaderCardUiState.ReaderPostUiState import org.wordpress.android.ui.reader.discover.ReaderCardUiState.ReaderRecommendedBlogsCardUiState +import org.wordpress.android.ui.reader.discover.viewholders.ReaderAnnouncementCardViewHolder import org.wordpress.android.ui.reader.discover.viewholders.ReaderInterestsCardNewViewHolder import org.wordpress.android.ui.reader.discover.viewholders.ReaderInterestsCardViewHolder import org.wordpress.android.ui.reader.discover.viewholders.ReaderPostNewViewHolder @@ -24,6 +26,7 @@ private const val POST_VIEW_TYPE: Int = 1 private const val INTEREST_VIEW_TYPE: Int = 2 private const val RECOMMENDED_BLOGS_VIEW_TYPE: Int = 3 private const val POST_NEW_VIEW_TYPE: Int = 4 +private const val READER_ANNOUNCEMENT_TYPE: Int = 5 class ReaderDiscoverAdapter( private val uiHelpers: UiHelpers, @@ -43,6 +46,7 @@ class ReaderDiscoverAdapter( networkUtilsWrapper, parent ) + INTEREST_VIEW_TYPE -> { if (isReaderImprovementsEnabled) { ReaderInterestsCardNewViewHolder(uiHelpers, parent) @@ -50,6 +54,7 @@ class ReaderDiscoverAdapter( ReaderInterestsCardViewHolder(uiHelpers, parent) } } + RECOMMENDED_BLOGS_VIEW_TYPE -> if (isReaderImprovementsEnabled) { ReaderRecommendedBlogsCardNewViewHolder( @@ -60,6 +65,9 @@ class ReaderDiscoverAdapter( parent, imageManager, uiHelpers ) } + + READER_ANNOUNCEMENT_TYPE -> ReaderAnnouncementCardViewHolder(parent) + else -> throw NotImplementedError("Unknown ViewType") } } @@ -93,6 +101,7 @@ class ReaderDiscoverAdapter( is ReaderPostNewUiState -> POST_NEW_VIEW_TYPE is ReaderInterestsCardUiState -> INTEREST_VIEW_TYPE is ReaderRecommendedBlogsCardUiState -> RECOMMENDED_BLOGS_VIEW_TYPE + is ReaderAnnouncementCardUiState -> READER_ANNOUNCEMENT_TYPE } } @@ -115,14 +124,17 @@ class ReaderDiscoverAdapter( is ReaderPostUiState -> { oldItem.postId == (newItem as ReaderPostUiState).postId && oldItem.blogId == newItem.blogId } + is ReaderPostNewUiState -> { oldItem.postId == (newItem as ReaderPostNewUiState).postId && oldItem.blogId == newItem.blogId } + is ReaderRecommendedBlogsCardUiState -> { val newItemState = newItem as? ReaderRecommendedBlogsCardUiState oldItem.blogs.map { it.blogId to it.feedId } == newItemState?.blogs?.map { it.blogId to it.feedId } } - is ReaderInterestsCardUiState -> { + + is ReaderInterestsCardUiState, is ReaderAnnouncementCardUiState -> { oldItem == newItem } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt index b50d61aa70ae..7408e7f23735 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt @@ -27,6 +27,7 @@ import org.wordpress.android.ui.reader.discover.ReaderNavigationEvents.ShowPosts import org.wordpress.android.ui.reader.discover.ReaderNavigationEvents.ShowReaderSubs import org.wordpress.android.ui.reader.discover.ReaderNavigationEvents.ShowSitePickerForResult import org.wordpress.android.ui.reader.reblog.ReblogUseCase +import org.wordpress.android.ui.reader.utils.ReaderAnnouncementHelper import org.wordpress.android.ui.reader.repository.ReaderDiscoverCommunication import org.wordpress.android.ui.reader.repository.ReaderDiscoverCommunication.Error import org.wordpress.android.ui.reader.repository.ReaderDiscoverCommunication.Started @@ -62,6 +63,7 @@ class ReaderDiscoverViewModel @Inject constructor( displayUtilsWrapper: DisplayUtilsWrapper, private val getFollowedTagsUseCase: GetFollowedTagsUseCase, private val readerImprovementsFeatureConfig: ReaderImprovementsFeatureConfig, + private val readerAnnouncementHelper: ReaderAnnouncementHelper, @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher ) : ScopedViewModel(mainDispatcher) { @@ -159,8 +161,19 @@ class ReaderDiscoverViewModel @Inject constructor( } } else { if (posts != null && posts.cards.isNotEmpty()) { + val announcement = if (readerAnnouncementHelper.hasReaderAnnouncement()) { + listOf( + ReaderCardUiState.ReaderAnnouncementCardUiState( + readerAnnouncementHelper.getReaderAnnouncementItems(), + ::dismissAnnouncementCard + ) + ) + } else { + emptyList() + } + _uiState.value = DiscoverUiState.ContentUiState( - convertCardsToUiStates(posts), + announcement + convertCardsToUiStates(posts), reloadProgressVisibility = false, loadMoreProgressVisibility = false, ) @@ -178,6 +191,15 @@ class ReaderDiscoverViewModel @Inject constructor( } } + private fun dismissAnnouncementCard() { + readerAnnouncementHelper.dismissReaderAnnouncement() + _uiState.value = (_uiState.value as? DiscoverUiState.ContentUiState)?.let { contentUiState -> + contentUiState.copy( + cards = contentUiState.cards.filterNot { it is ReaderCardUiState.ReaderAnnouncementCardUiState } + ) + } + } + private fun observeFollowStatus() { // listen to changes on follow status for updating the reader recommended blogs state immediately _uiState.addSource(readerPostCardActionsHandler.followStatusUpdated) { data -> diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/viewholders/ReaderAnnouncementCardViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/viewholders/ReaderAnnouncementCardViewHolder.kt new file mode 100644 index 000000000000..c2c5bb83494d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/viewholders/ReaderAnnouncementCardViewHolder.kt @@ -0,0 +1,21 @@ +package org.wordpress.android.ui.reader.discover.viewholders + +import android.view.ViewGroup +import org.wordpress.android.databinding.ReaderCardviewAnnouncementBinding +import org.wordpress.android.ui.reader.discover.ReaderCardUiState +import org.wordpress.android.util.extensions.viewBinding + +class ReaderAnnouncementCardViewHolder( + parentView: ViewGroup, +) : ReaderViewHolder( + parentView.viewBinding(ReaderCardviewAnnouncementBinding::inflate) +) { + override fun onBind(uiState: ReaderCardUiState) { + (uiState as? ReaderCardUiState.ReaderAnnouncementCardUiState)?.let { state -> + with(binding.root) { + setItems(state.items) + setOnDoneClickListener(state.onDoneClick) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/exceptions/ReaderPostFetchException.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/exceptions/ReaderPostFetchException.kt new file mode 100644 index 000000000000..de4cc47ae7df --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/exceptions/ReaderPostFetchException.kt @@ -0,0 +1,5 @@ +package org.wordpress.android.ui.reader.exceptions + +class ReaderPostFetchException( + message: String = "Failed to fetch post(s).", +) : RuntimeException(message) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/repository/ReaderPostRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/repository/ReaderPostRepository.kt new file mode 100644 index 000000000000..503682566dbb --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/repository/ReaderPostRepository.kt @@ -0,0 +1,255 @@ +package org.wordpress.android.ui.reader.repository + +import com.android.volley.VolleyError +import com.wordpress.rest.RestRequest +import dagger.Reusable +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import org.json.JSONObject +import org.wordpress.android.WordPress.Companion.getRestClientUtilsV1_2 +import org.wordpress.android.datasets.ReaderPostTable +import org.wordpress.android.datasets.ReaderTagTable +import org.wordpress.android.models.ReaderPostList +import org.wordpress.android.models.ReaderTag +import org.wordpress.android.models.ReaderTagType +import org.wordpress.android.modules.IO_THREAD +import org.wordpress.android.ui.reader.ReaderConstants +import org.wordpress.android.ui.reader.actions.ReaderActions +import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateResultListener +import org.wordpress.android.ui.reader.exceptions.ReaderPostFetchException +import org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter +import org.wordpress.android.ui.reader.sources.ReaderPostLocalSource +import org.wordpress.android.ui.reader.utils.ReaderUtils +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.LocaleManagerWrapper +import org.wordpress.android.util.UrlUtils +import java.util.Locale +import javax.inject.Inject +import javax.inject.Named +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Reusable +class ReaderPostRepository @Inject constructor( + private val localeManagerWrapper: LocaleManagerWrapper, + private val localSource: ReaderPostLocalSource, + @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, +) { + /** + * Fetches and returns the most recent posts for the passed tag, respecting the maxPosts limit. + * It always fetches the most recent posts, saves them to the local DB and returns the latest from that cache. + */ + suspend fun fetchNewerPostsForTag(tag: ReaderTag, maxPosts: Int = 10): ReaderPostList = withContext(ioDispatcher) { + suspendCancellableCoroutine { cont -> + val resultListener = UpdateResultListener { result -> + if (result == ReaderActions.UpdateResult.FAILED) { + cont.resumeWithException( + ReaderPostFetchException("Failed to fetch newer posts for tag: ${tag.tagSlug}") + ) + } else { + val posts = ReaderPostTable.getPostsWithTag(tag, maxPosts, false) + cont.resume(posts) + } + } + requestPostsWithTag(tag, ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER, resultListener) + } + } + + fun requestPostsWithTag( + tag: ReaderTag, + updateAction: ReaderPostServiceStarter.UpdateAction, + resultListener: UpdateResultListener + ) { + val path = getRelativeEndpointForTag(tag) + if (path.isNullOrBlank()) { + resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED) + return + } + val sb = StringBuilder(path) + + // append #posts to retrieve + sb.append("?number=").append(ReaderConstants.READER_MAX_POSTS_TO_REQUEST) + + // return newest posts first (this is the default, but make it explicit since it's important) + sb.append("&order=DESC") + + val beforeDate: String? = when (updateAction) { + ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER -> { + // request posts older than the oldest existing post with this tag + ReaderPostTable.getOldestDateWithTag(tag) + } + + ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER_THAN_GAP -> { + // request posts older than the post with the gap marker for this tag + ReaderPostTable.getGapMarkerDateForTag(tag) + } + + ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER, + ReaderPostServiceStarter.UpdateAction.REQUEST_REFRESH -> null + } + + if (!beforeDate.isNullOrBlank()) { + sb.append("&before=").append(UrlUtils.urlEncode(beforeDate)) + } + sb.append("&meta=site,likes") + sb.append("&lang=").append(localeManagerWrapper.getLanguage()) + + val listener = RestRequest.Listener { jsonObject: JSONObject? -> + // remember when this tag was updated if newer posts were requested + if (updateAction == ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER || + updateAction == ReaderPostServiceStarter.UpdateAction.REQUEST_REFRESH + ) { + ReaderTagTable.setTagLastUpdated(tag) + } + handleUpdatePostsResponse(tag, jsonObject, updateAction, resultListener) + } + + val errorListener = RestRequest.ErrorListener { volleyError: VolleyError? -> + AppLog.e(AppLog.T.READER, volleyError) + resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED) + } + + getRestClientUtilsV1_2().get(sb.toString(), null, null, listener, errorListener) + } + + fun requestPostsForBlog( + blogId: Long, + updateAction: ReaderPostServiceStarter.UpdateAction, + resultListener: UpdateResultListener + ) { + var path = "read/sites/$blogId/posts/?meta=site,likes" + + // append the date of the oldest cached post in this blog when requesting older posts + if (updateAction == ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER) { + val dateOldest = ReaderPostTable.getOldestPubDateInBlog(blogId) + if (!dateOldest.isNullOrBlank()) { + path += "&before=" + UrlUtils.urlEncode(dateOldest) + } + } + val listener = RestRequest.Listener { jsonObject -> + handleUpdatePostsResponse( + null, + jsonObject, + updateAction, + resultListener + ) + } + val errorListener = RestRequest.ErrorListener { volleyError -> + AppLog.e(AppLog.T.READER, volleyError) + resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED) + } + AppLog.d(AppLog.T.READER, "updating posts in blog $blogId") + getRestClientUtilsV1_2().getWithLocale(path, null, null, listener, errorListener) + } + + fun requestPostsForFeed( + feedId: Long, + updateAction: ReaderPostServiceStarter.UpdateAction, + resultListener: UpdateResultListener + ) { + var path = "read/feed/$feedId/posts/?meta=site,likes" + if (updateAction == ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER) { + val dateOldest = ReaderPostTable.getOldestPubDateInFeed(feedId) + if (!dateOldest.isNullOrBlank()) { + path += "&before=" + UrlUtils.urlEncode(dateOldest) + } + } + val listener = RestRequest.Listener { jsonObject -> + handleUpdatePostsResponse( + null, + jsonObject, + updateAction, + resultListener + ) + } + val errorListener = RestRequest.ErrorListener { volleyError -> + AppLog.e(AppLog.T.READER, volleyError) + resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED) + } + AppLog.d(AppLog.T.READER, "updating posts in feed $feedId") + getRestClientUtilsV1_2().getWithLocale(path, null, null, listener, errorListener) + } + + /** + * called after requesting posts with a specific tag or in a specific blog/feed + */ + private fun handleUpdatePostsResponse( + tag: ReaderTag?, + jsonObject: JSONObject?, + updateAction: ReaderPostServiceStarter.UpdateAction, + resultListener: UpdateResultListener + ) { + if (jsonObject == null) { + resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED) + return + } + + // this should ideally be done using coroutines, but this class is currently being used from Java, which makes + // it difficult to use coroutines. This should be refactored to use coroutines when possible. + object : Thread() { + override fun run() { + val serverPosts = ReaderPostList.fromJson(jsonObject) + val updateResult = localSource.saveUpdatedPosts(serverPosts, updateAction, tag) + resultListener.onUpdateResult(updateResult) + } + }.start() + } + + /** + * returns the endpoint to use when requesting posts with the passed tag + */ + private fun getRelativeEndpointForTag(tag: ReaderTag): String? { + val endpoint = tag.endpoint?.takeIf { it.isNotBlank() } // if passed tag has an assigned endpoint, use it + ?: ReaderTagTable.getEndpointForTag(tag)?.takeIf { it.isNotBlank() } // check the db for the endpoint + + return endpoint + ?.let { getRelativeEndpoint(it) } + ?: if (tag.tagType == ReaderTagType.DEFAULT) { + // never hand craft the endpoint for default tags, since these MUST be updated using their endpoints + null + } else { + formatRelativeEndpointForTag(tag.tagSlug) + } + } + + private fun formatRelativeEndpointForTag(tagSlug: String): String { + return String.format(Locale.US, "read/tags/%s/posts", ReaderUtils.sanitizeWithDashes(tagSlug)) + } + + /** + * returns the passed endpoint without the unnecessary path - this is + * needed because as of 20-Feb-2015 the /read/menu/ call returns the + * full path but we don't want to use the full path since it may change + * between API versions (as it did when we moved from v1 to v1.1) + * + * ex: https://public-api.wordpress.com/rest/v1/read/tags/fitness/posts + * becomes just read/tags/fitness/posts + */ + @Suppress("MagicNumber") + private fun getRelativeEndpoint(endpoint: String): String { + return endpoint.takeIf { it.startsWith("http") } + ?.let { + var pos = it.indexOf("/read/") + if (pos > -1) { + return@let it.substring(pos + 1) + } + pos = it.indexOf("/v1/") + if (pos > -1) { + return@let it.substring(pos + 4) + } + return@let it + } + ?: endpoint + } + + companion object { + private fun formatRelativeEndpointForTag(tagSlug: String): String { + return String.format(Locale.US, "read/tags/%s/posts", ReaderUtils.sanitizeWithDashes(tagSlug)) + } + + fun formatFullEndpointForTag(tagSlug: String): String { + return (getRestClientUtilsV1_2().restClient.endpointURL + formatRelativeEndpointForTag(tagSlug)) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostJobService.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostJobService.java index 70a2ed80efb8..b0cfb5d519db 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostJobService.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostJobService.java @@ -10,12 +10,9 @@ import org.wordpress.android.ui.reader.ReaderEvents; import org.wordpress.android.ui.reader.services.ServiceCompletionListener; import org.wordpress.android.util.AppLog; -import org.wordpress.android.util.LocaleManagerWrapper; import javax.inject.Inject; -import dagger.hilt.android.AndroidEntryPoint; - import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_ACTION; import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_BLOG_ID; import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_FEED_ID; @@ -26,6 +23,8 @@ import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_TAG_PARAM_TITLE; import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.UpdateAction; +import dagger.hilt.android.AndroidEntryPoint; + /** * service which updates posts with specific tags or in specific blogs/feeds - relies on * EventBus to alert of update status @@ -33,10 +32,9 @@ @AndroidEntryPoint public class ReaderPostJobService extends JobService implements ServiceCompletionListener { + @Inject ReaderPostLogicFactory mPostLogicFactory; private ReaderPostLogic mReaderPostLogic; - @Inject LocaleManagerWrapper mLocaleManagerWrapper; - @Override public boolean onStartJob(JobParameters params) { AppLog.i(AppLog.T.READER, "reader post job service > started"); UpdateAction action; @@ -74,7 +72,7 @@ public class ReaderPostJobService extends JobService implements ServiceCompletio @Override public void onCreate() { super.onCreate(); - mReaderPostLogic = new ReaderPostLogic(this, mLocaleManagerWrapper); + mReaderPostLogic = mPostLogicFactory.create(this); AppLog.i(AppLog.T.READER, "reader post job service > created"); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogic.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogic.java index 212d32230fab..d3b9dd20a8dd 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogic.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogic.java @@ -1,43 +1,26 @@ package org.wordpress.android.ui.reader.services.post; -import android.text.TextUtils; - import androidx.annotation.NonNull; -import com.android.volley.VolleyError; -import com.wordpress.rest.RestRequest; - import org.greenrobot.eventbus.EventBus; -import org.json.JSONObject; -import org.wordpress.android.WordPress; -import org.wordpress.android.datasets.ReaderPostTable; -import org.wordpress.android.datasets.ReaderTagTable; -import org.wordpress.android.models.ReaderPost; -import org.wordpress.android.models.ReaderPostList; import org.wordpress.android.models.ReaderTag; -import org.wordpress.android.models.ReaderTagType; -import org.wordpress.android.ui.prefs.AppPrefs; -import org.wordpress.android.ui.reader.ReaderConstants; import org.wordpress.android.ui.reader.ReaderEvents; import org.wordpress.android.ui.reader.actions.ReaderActions; -import org.wordpress.android.ui.reader.models.ReaderBlogIdPostId; +import org.wordpress.android.ui.reader.repository.ReaderPostRepository; import org.wordpress.android.ui.reader.services.ServiceCompletionListener; import org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.UpdateAction; -import org.wordpress.android.ui.reader.utils.ReaderUtils; -import org.wordpress.android.util.AppLog; -import org.wordpress.android.util.LocaleManagerWrapper; -import org.wordpress.android.util.StringUtils; -import org.wordpress.android.util.UrlUtils; public class ReaderPostLogic { - private ServiceCompletionListener mCompletionListener; - private final LocaleManagerWrapper mLocaleManagerWrapper; + @NonNull + private final ServiceCompletionListener mCompletionListener; + @NonNull + private final ReaderPostRepository mReaderPostRepository; private Object mListenerCompanion; public ReaderPostLogic(@NonNull final ServiceCompletionListener listener, - @NonNull final LocaleManagerWrapper localeManagerWrapper) { + @NonNull final ReaderPostRepository readerPostRepository) { mCompletionListener = listener; - mLocaleManagerWrapper = localeManagerWrapper; + mReaderPostRepository = readerPostRepository; } public void performTask(Object companion, UpdateAction action, @@ -55,9 +38,8 @@ public void performTask(Object companion, UpdateAction action, } } - private void updatePostsWithTag(final ReaderTag tag, final UpdateAction action) { - requestPostsWithTag( + mReaderPostRepository.requestPostsWithTag( tag, action, new ReaderActions.UpdateResultListener() { @@ -77,7 +59,7 @@ public void onUpdateResult(ReaderActions.UpdateResult result) { mCompletionListener.onCompleted(mListenerCompanion); } }; - requestPostsForBlog(blogId, action, listener); + mReaderPostRepository.requestPostsForBlog(blogId, action, listener); } private void updatePostsInFeed(long feedId, final UpdateAction action) { @@ -88,261 +70,6 @@ public void onUpdateResult(ReaderActions.UpdateResult result) { mCompletionListener.onCompleted(mListenerCompanion); } }; - requestPostsForFeed(feedId, action, listener); - } - - private void requestPostsWithTag(final ReaderTag tag, - final UpdateAction updateAction, - final ReaderActions.UpdateResultListener resultListener) { - String path = getRelativeEndpointForTag(tag); - if (TextUtils.isEmpty(path)) { - resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED); - return; - } - - StringBuilder sb = new StringBuilder(path); - - // append #posts to retrieve - sb.append("?number=").append(ReaderConstants.READER_MAX_POSTS_TO_REQUEST); - - // return newest posts first (this is the default, but make it explicit since it's important) - sb.append("&order=DESC"); - - String beforeDate; - switch (updateAction) { - case REQUEST_OLDER: - // request posts older than the oldest existing post with this tag - beforeDate = ReaderPostTable.getOldestDateWithTag(tag); - break; - case REQUEST_OLDER_THAN_GAP: - // request posts older than the post with the gap marker for this tag - beforeDate = ReaderPostTable.getGapMarkerDateForTag(tag); - break; - case REQUEST_NEWER: - case REQUEST_REFRESH: - default: - beforeDate = null; - break; - } - if (!TextUtils.isEmpty(beforeDate)) { - sb.append("&before=").append(UrlUtils.urlEncode(beforeDate)); - } - - sb.append("&meta=site,likes"); - - sb.append("&lang=").append(mLocaleManagerWrapper.getLanguage()); - - com.wordpress.rest.RestRequest.Listener listener = jsonObject -> { - // remember when this tag was updated if newer posts were requested - if (updateAction == UpdateAction.REQUEST_NEWER || updateAction == UpdateAction.REQUEST_REFRESH) { - ReaderTagTable.setTagLastUpdated(tag); - } - handleUpdatePostsResponse(tag, jsonObject, updateAction, resultListener); - }; - RestRequest.ErrorListener errorListener = volleyError -> { - AppLog.e(AppLog.T.READER, volleyError); - resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED); - }; - - WordPress.getRestClientUtilsV1_2().get(sb.toString(), null, null, listener, errorListener); - } - - private static void requestPostsForBlog(final long blogId, - final UpdateAction updateAction, - final ReaderActions.UpdateResultListener resultListener) { - String path = "read/sites/" + blogId + "/posts/?meta=site,likes"; - - // append the date of the oldest cached post in this blog when requesting older posts - if (updateAction == UpdateAction.REQUEST_OLDER) { - String dateOldest = ReaderPostTable.getOldestPubDateInBlog(blogId); - if (!TextUtils.isEmpty(dateOldest)) { - path += "&before=" + UrlUtils.urlEncode(dateOldest); - } - } - - com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() { - @Override - public void onResponse(JSONObject jsonObject) { - handleUpdatePostsResponse(null, jsonObject, updateAction, resultListener); - } - }; - RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { - @Override - public void onErrorResponse(VolleyError volleyError) { - AppLog.e(AppLog.T.READER, volleyError); - resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED); - } - }; - AppLog.d(AppLog.T.READER, "updating posts in blog " + blogId); - WordPress.getRestClientUtilsV1_2().getWithLocale(path, null, null, listener, errorListener); - } - - private static void requestPostsForFeed(final long feedId, - final UpdateAction updateAction, - final ReaderActions.UpdateResultListener resultListener) { - String path = "read/feed/" + feedId + "/posts/?meta=site,likes"; - if (updateAction == UpdateAction.REQUEST_OLDER) { - String dateOldest = ReaderPostTable.getOldestPubDateInFeed(feedId); - if (!TextUtils.isEmpty(dateOldest)) { - path += "&before=" + UrlUtils.urlEncode(dateOldest); - } - } - - com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() { - @Override - public void onResponse(JSONObject jsonObject) { - handleUpdatePostsResponse(null, jsonObject, updateAction, resultListener); - } - }; - RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { - @Override - public void onErrorResponse(VolleyError volleyError) { - AppLog.e(AppLog.T.READER, volleyError); - resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED); - } - }; - - AppLog.d(AppLog.T.READER, "updating posts in feed " + feedId); - WordPress.getRestClientUtilsV1_2().getWithLocale(path, null, null, listener, errorListener); - } - - /* - * called after requesting posts with a specific tag or in a specific blog/feed - */ - private static void handleUpdatePostsResponse(final ReaderTag tag, - final JSONObject jsonObject, - final UpdateAction updateAction, - final ReaderActions.UpdateResultListener resultListener) { - if (jsonObject == null) { - resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED); - return; - } - - new Thread() { - @Override - public void run() { - ReaderPostList serverPosts = ReaderPostList.fromJson(jsonObject); - ReaderActions.UpdateResult updateResult = ReaderPostTable.comparePosts(serverPosts); - if (updateResult.isNewOrChanged()) { - // gap detection - only applies to posts with a specific tag - ReaderPost postWithGap = null; - if (tag != null) { - switch (updateAction) { - case REQUEST_NEWER: - // if there's no overlap between server and local (ie: all server - // posts are new), assume there's a gap between server and local - // provided that local posts exist - int numServerPosts = serverPosts.size(); - if (numServerPosts >= 2 - && ReaderPostTable.getNumPostsWithTag(tag) > 0 - && !ReaderPostTable.hasOverlap(serverPosts, tag)) { - // treat the second to last server post as having a gap - postWithGap = serverPosts.get(numServerPosts - 2); - // remove the last server post to deal with the edge case of - // there actually not being a gap between local & server - serverPosts.remove(numServerPosts - 1); - ReaderBlogIdPostId gapMarker = ReaderPostTable.getGapMarkerIdsForTag(tag); - if (gapMarker != null) { - // We mustn't have two gapMarkers at the same time. Therefor we need to - // delete all posts before the current gapMarker and clear the gapMarker flag. - ReaderPostTable.deletePostsBeforeGapMarkerForTag(tag); - ReaderPostTable.removeGapMarkerForTag(tag); - } - } - break; - case REQUEST_OLDER_THAN_GAP: - // if service was started as a request to fill a gap, delete existing posts - // before the one with the gap marker, then remove the existing gap marker - ReaderPostTable.deletePostsBeforeGapMarkerForTag(tag); - ReaderPostTable.removeGapMarkerForTag(tag); - break; - case REQUEST_REFRESH: - ReaderPostTable.deletePostsWithTag(tag); - break; - case REQUEST_OLDER: - // no-op - break; - } - } - ReaderPostTable.addOrUpdatePosts(tag, serverPosts); - if (AppPrefs.shouldUpdateBookmarkPostsPseudoIds(tag)) { - ReaderPostTable.updateBookmarkedPostPseudoId(serverPosts); - AppPrefs.setBookmarkPostsPseudoIdsUpdated(); - } - - // gap marker must be set after saving server posts - if (postWithGap != null) { - ReaderPostTable.setGapMarkerForTag(postWithGap.blogId, postWithGap.postId, tag); - AppLog.d(AppLog.T.READER, "added gap marker to tag " + tag.getTagNameForLog()); - } - } else if (updateResult == ReaderActions.UpdateResult.UNCHANGED - && updateAction == UpdateAction.REQUEST_OLDER_THAN_GAP) { - // edge case - request to fill gap returned nothing new, so remove the gap marker - ReaderPostTable.removeGapMarkerForTag(tag); - AppLog.w(AppLog.T.READER, "attempt to fill gap returned nothing new"); - } - AppLog.d(AppLog.T.READER, "requested posts response = " + updateResult.toString()); - resultListener.onUpdateResult(updateResult); - } - }.start(); - } - - /* - * returns the endpoint to use when requesting posts with the passed tag - */ - private static String getRelativeEndpointForTag(ReaderTag tag) { - if (tag == null) { - return null; - } - - // if passed tag has an assigned endpoint, return it and be done - if (!TextUtils.isEmpty(tag.getEndpoint())) { - return getRelativeEndpoint(tag.getEndpoint()); - } - - // check the db for the endpoint - String endpoint = ReaderTagTable.getEndpointForTag(tag); - if (!TextUtils.isEmpty(endpoint)) { - return getRelativeEndpoint(endpoint); - } - - // never hand craft the endpoint for default tags, since these MUST be updated - // using their stored endpoints - if (tag.tagType == ReaderTagType.DEFAULT) { - return null; - } - return formatRelativeEndpointForTag(tag.getTagSlug()); - } - - private static String formatRelativeEndpointForTag(@NonNull final String tagSlug) { - return String.format("read/tags/%s/posts", ReaderUtils.sanitizeWithDashes(tagSlug)); - } - - public static String formatFullEndpointForTag(@NonNull final String tagSlug) { - return WordPress.getRestClientUtilsV1_2().getRestClient().getEndpointURL() - + formatRelativeEndpointForTag(tagSlug); - } - - /* - * returns the passed endpoint without the unnecessary path - this is - * needed because as of 20-Feb-2015 the /read/menu/ call returns the - * full path but we don't want to use the full path since it may change - * between API versions (as it did when we moved from v1 to v1.1) - * - * ex: https://public-api.wordpress.com/rest/v1/read/tags/fitness/posts - * becomes just read/tags/fitness/posts - */ - private static String getRelativeEndpoint(final String endpoint) { - if (endpoint != null && endpoint.startsWith("http")) { - int pos = endpoint.indexOf("/read/"); - if (pos > -1) { - return endpoint.substring(pos + 1, endpoint.length()); - } - pos = endpoint.indexOf("/v1/"); - if (pos > -1) { - return endpoint.substring(pos + 4, endpoint.length()); - } - } - return StringUtils.notNullStr(endpoint); + mReaderPostRepository.requestPostsForFeed(feedId, action, listener); } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogicFactory.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogicFactory.kt new file mode 100644 index 000000000000..952ef6b31db8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogicFactory.kt @@ -0,0 +1,14 @@ +package org.wordpress.android.ui.reader.services.post + +import org.wordpress.android.ui.reader.repository.ReaderPostRepository +import org.wordpress.android.ui.reader.services.ServiceCompletionListener +import javax.inject.Inject + +class ReaderPostLogicFactory @Inject constructor( + private val readerPostRepository: ReaderPostRepository, +) { + fun create(listener: ServiceCompletionListener): ReaderPostLogic = ReaderPostLogic( + listener, + readerPostRepository, + ) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostService.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostService.java index 5753d9ce4cb7..8312575d0124 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostService.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostService.java @@ -9,18 +9,17 @@ import org.wordpress.android.ui.reader.ReaderEvents; import org.wordpress.android.ui.reader.services.ServiceCompletionListener; import org.wordpress.android.util.AppLog; -import org.wordpress.android.util.LocaleManagerWrapper; import javax.inject.Inject; -import dagger.hilt.android.AndroidEntryPoint; - import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_ACTION; import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_BLOG_ID; import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_FEED_ID; import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_TAG; import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.UpdateAction; +import dagger.hilt.android.AndroidEntryPoint; + /** * service which updates posts with specific tags or in specific blogs/feeds - relies on * EventBus to alert of update status @@ -28,10 +27,9 @@ @AndroidEntryPoint public class ReaderPostService extends Service implements ServiceCompletionListener { + @Inject ReaderPostLogicFactory mPostLogicFactory; private ReaderPostLogic mReaderPostLogic; - @Inject LocaleManagerWrapper mLocaleManagerWrapper; - @Override public IBinder onBind(Intent intent) { return null; @@ -40,7 +38,7 @@ public IBinder onBind(Intent intent) { @Override public void onCreate() { super.onCreate(); - mReaderPostLogic = new ReaderPostLogic(this, mLocaleManagerWrapper); + mReaderPostLogic = mPostLogicFactory.create(this); AppLog.i(AppLog.T.READER, "reader post service > created"); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/sources/ReaderPostLocalSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/sources/ReaderPostLocalSource.kt new file mode 100644 index 000000000000..b649d5fb55ec --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/sources/ReaderPostLocalSource.kt @@ -0,0 +1,127 @@ +package org.wordpress.android.ui.reader.sources + +import dagger.Reusable +import org.wordpress.android.datasets.wrappers.ReaderPostTableWrapper +import org.wordpress.android.models.ReaderPost +import org.wordpress.android.models.ReaderPostList +import org.wordpress.android.models.ReaderTag +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.ui.reader.actions.ReaderActions +import org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter +import org.wordpress.android.util.AppLog +import javax.inject.Inject + +/** + * Manage the saving of posts to the local database table. + */ +@Reusable +class ReaderPostLocalSource @Inject constructor( + private val readerPostTableWrapper: ReaderPostTableWrapper, + private val appPrefsWrapper: AppPrefsWrapper, +) { + /** + * Save the list of posts to the local database, and handle any gaps between local and server posts. + * + * Ideally this should be a suspend function but since it's being ultimately used by Java in some scenarios we + * are keeping it blocking for now and it's up to the caller to run it in a coroutine or different thread. + */ + fun saveUpdatedPosts( + serverPosts: ReaderPostList, + updateAction: ReaderPostServiceStarter.UpdateAction, + requestedTag: ReaderTag?, + ): ReaderActions.UpdateResult { + val updateResult = readerPostTableWrapper.comparePosts(serverPosts) + if (updateResult.isNewOrChanged) { + // gap detection - only applies to posts with a specific tag + var postWithGap: ReaderPost? = null + if (requestedTag != null) { + when (updateAction) { + ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER -> { + postWithGap = handleRequestNewerResult(serverPosts, requestedTag) + } + + ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER_THAN_GAP -> { + handleRequestOlderThanGapResult(requestedTag) + } + + ReaderPostServiceStarter.UpdateAction.REQUEST_REFRESH -> readerPostTableWrapper.deletePostsWithTag( + requestedTag + ) + + ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER -> { + /* noop */ + } + } + } + + // save posts to local db + readerPostTableWrapper.addOrUpdatePosts(requestedTag, serverPosts) + + if (appPrefsWrapper.shouldUpdateBookmarkPostsPseudoIds(requestedTag)) { + readerPostTableWrapper.updateBookmarkedPostPseudoId(serverPosts) + appPrefsWrapper.setBookmarkPostsPseudoIdsUpdated() + } + + // gap marker must be set after saving server posts + if (postWithGap != null && requestedTag != null) { + readerPostTableWrapper.setGapMarkerForTag(postWithGap.blogId, postWithGap.postId, requestedTag) + AppLog.d(AppLog.T.READER, "added gap marker to tag " + requestedTag.tagNameForLog) + } + } else if (updateResult == ReaderActions.UpdateResult.UNCHANGED + && updateAction == ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER_THAN_GAP + && requestedTag != null + ) { + // edge case - request to fill gap returned nothing new, so remove the gap marker + readerPostTableWrapper.removeGapMarkerForTag(requestedTag) + AppLog.w(AppLog.T.READER, "attempt to fill gap returned nothing new") + } + AppLog.d( + AppLog.T.READER, + "requested posts response = $updateResult" + ) + return updateResult + } + + private fun handleRequestOlderThanGapResult(requestedTag: ReaderTag) { + // if service was started as a request to fill a gap, delete existing posts + // before the one with the gap marker, then remove the existing gap marker + readerPostTableWrapper.deletePostsBeforeGapMarkerForTag(requestedTag) + readerPostTableWrapper.removeGapMarkerForTag(requestedTag) + } + + /** + * Handle the result of a request for newer posts, which may include a gap between local and server posts. + * + * @return the post that has a gap, or null if there's no gap + */ + private fun handleRequestNewerResult( + serverPosts: ReaderPostList, + requestedTag: ReaderTag, + ): ReaderPost? { + // if there's no overlap between server and local (ie: all server + // posts are new), assume there's a gap between server and local + // provided that local posts exist + var postWithGap: ReaderPost? = null + val numServerPosts = serverPosts.size + if (numServerPosts >= 2 && readerPostTableWrapper.getNumPostsWithTag(requestedTag) > 0 && + !readerPostTableWrapper.hasOverlap( + serverPosts, + requestedTag + ) + ) { + // treat the second to last server post as having a gap + postWithGap = serverPosts[numServerPosts - 2] + // remove the last server post to deal with the edge case of + // there actually not being a gap between local & server + serverPosts.removeAt(numServerPosts - 1) + val gapMarker = readerPostTableWrapper.getGapMarkerIdsForTag(requestedTag) + if (gapMarker != null) { + // We mustn't have two gapMarkers at the same time. Therefor we need to + // delete all posts before the current gapMarker and clear the gapMarker flag. + readerPostTableWrapper.deletePostsBeforeGapMarkerForTag(requestedTag) + readerPostTableWrapper.removeGapMarkerForTag(requestedTag) + } + } + return postWithGap + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModel.kt index 5f806bb6ad8b..8f41c16c70e7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModel.kt @@ -9,8 +9,8 @@ import kotlinx.coroutines.withContext import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.wordpress.android.analytics.AnalyticsTracker.Stat -import org.wordpress.android.datasets.ReaderBlogTable -import org.wordpress.android.datasets.ReaderTagTable +import org.wordpress.android.datasets.ReaderBlogTableWrapper +import org.wordpress.android.datasets.wrappers.ReaderTagTableWrapper import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.models.ReaderBlog import org.wordpress.android.models.ReaderTag @@ -38,7 +38,6 @@ import org.wordpress.android.util.UrlUtils import org.wordpress.android.viewmodel.Event import org.wordpress.android.viewmodel.ScopedViewModel import org.wordpress.android.viewmodel.SingleLiveEvent -import java.util.Comparator import java.util.EnumSet import javax.inject.Inject import javax.inject.Named @@ -50,7 +49,9 @@ class SubFilterViewModel @Inject constructor( private val subfilterListItemMapper: SubfilterListItemMapper, private val eventBusWrapper: EventBusWrapper, private val accountStore: AccountStore, - private val readerTracker: ReaderTracker + private val readerTracker: ReaderTracker, + private val readerTagTableWrapper: ReaderTagTableWrapper, + private val readerBlogTableWrapper: ReaderBlogTableWrapper, ) : ScopedViewModel(bgDispatcher) { private val _subFilters = MutableLiveData>() val subFilters: LiveData> = _subFilters @@ -125,6 +126,7 @@ class SubFilterViewModel @Inject constructor( "" } } + fun loadSubFilters() { launch { val filterList = ArrayList() @@ -132,19 +134,19 @@ class SubFilterViewModel @Inject constructor( if (accountStore.hasAccessToken()) { val organization = mTagFragmentStartedWith?.organization - val followedBlogs = ReaderBlogTable.getFollowedBlogs().let { blogList -> + val followedBlogs = readerBlogTableWrapper.getFollowedBlogs().let { blogList -> // Filtering out all blogs not belonging to this VM organization if valid blogList.filter { blog -> organization?.let { blog.organizationId == organization.orgId } ?: false } - }.sortedWith(Comparator { blog1, blog2 -> + }.sortedWith { blog1, blog2 -> // sort followed blogs by name/domain to match display val blogOneName = getBlogNameForComparison(blog1) val blogTwoName = getBlogNameForComparison(blog2) blogOneName.compareTo(blogTwoName, true) - }) + } filterList.addAll( followedBlogs.map { blog -> @@ -157,7 +159,7 @@ class SubFilterViewModel @Inject constructor( ) } - val tags = ReaderTagTable.getFollowedTags() + val tags = readerTagTableWrapper.getFollowedTags() for (tag in tags) { filterList.add( @@ -217,7 +219,15 @@ class SubFilterViewModel @Inject constructor( } fun setDefaultSubfilter(isClearingFilter: Boolean) { - readerTracker.track(Stat.READER_FILTER_SHEET_CLEARED) + val filterItemType = FilterItemType.fromSubfilterListItem(getCurrentSubfilterValue()) + if (filterItemType != null) { + readerTracker.track( + Stat.READER_FILTER_SHEET_CLEARED, + mutableMapOf(FilterItemType.trackingEntry(filterItemType)) + ) + } else { + readerTracker.track(Stat.READER_FILTER_SHEET_CLEARED) + } updateSubfilter( filter = SiteAll( onClickAction = ::onSubfilterClicked, @@ -231,13 +241,14 @@ class SubFilterViewModel @Inject constructor( category: SubfilterCategory, ) { updateTagsAndSites() + loadSubFilters() _bottomSheetUiState.value = Event( BottomSheetVisible( UiStringRes(category.titleRes), category ) ) - val source = when(category) { + val source = when (category) { SubfilterCategory.SITES -> "blogs" SubfilterCategory.TAGS -> "tags" } @@ -315,9 +326,17 @@ class SubFilterViewModel @Inject constructor( } fun onSubfilterSelected(subfilterListItem: SubfilterListItem) { - // We should not track subfilter selected if we're clearing a filter that is currently applied. - if (!subfilterListItem.isClearingFilter) { - readerTracker.track(Stat.READER_FILTER_SHEET_ITEM_SELECTED) + // We should only track the selection of a subfilter if it's a tracked item (meaning it's a valid tag or site) + if (subfilterListItem.isTrackedItem) { + val filterItemType = FilterItemType.fromSubfilterListItem(subfilterListItem) + if (filterItemType != null) { + readerTracker.track( + Stat.READER_FILTER_SHEET_ITEM_SELECTED, + mutableMapOf(FilterItemType.trackingEntry(filterItemType)) + ) + } else { + readerTracker.track(Stat.READER_FILTER_SHEET_ITEM_SELECTED) + } } changeSubfilter(subfilterListItem, true, mTagFragmentStartedWith) } @@ -338,6 +357,7 @@ class SubFilterViewModel @Inject constructor( readerTracker.stop(ReaderTrackerType.SUBFILTERED_LIST) } _currentSubFilter.value = filter + onSubfilterSelected(filter) } fun onUserComesToReader() { @@ -398,10 +418,10 @@ class SubFilterViewModel @Inject constructor( loadSubFilters() } - @Suppress("unused", "UNUSED_PARAMETER") + @Suppress("unused") @Subscribe(threadMode = ThreadMode.MAIN) fun onEventMainThread(event: ReaderEvents.FollowedBlogsFetched) { - if(event.didChange()) { + if (event.didChange()) { AppLog.d(T.READER, "Subfilter bottom sheet > followed blogs changed") loadSubFilters() } @@ -425,4 +445,22 @@ class SubFilterViewModel @Inject constructor( return SUBFILTER_VM_BASE_KEY + tag.keyString } } + + sealed class FilterItemType(val trackingValue: String) { + data object Tag : FilterItemType("topic") + + data object Blog : FilterItemType("site") + + companion object { + fun fromSubfilterListItem(subfilterListItem: SubfilterListItem): FilterItemType? = + when (subfilterListItem.type) { + SubfilterListItem.ItemType.SITE -> Blog + SubfilterListItem.ItemType.TAG -> Tag + else -> null + } + + fun trackingEntry(filterItemType: FilterItemType): Pair = + "type" to filterItemType.trackingValue + } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelProvider.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelProvider.kt new file mode 100644 index 000000000000..33057daf33cd --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelProvider.kt @@ -0,0 +1,66 @@ +package org.wordpress.android.ui.reader.subfilter + +import android.os.Bundle +import androidx.fragment.app.Fragment +import org.wordpress.android.models.ReaderTag + +interface SubFilterViewModelProvider { + fun getSubFilterViewModelForKey(key: String): SubFilterViewModel + fun getSubFilterViewModelForTag(tag: ReaderTag, savedInstanceState: Bundle? = null): SubFilterViewModel + + companion object { + /** + * Helper function to get the [SubFilterViewModel] for a given [ReaderTag] from a [Fragment]. Note that the + * [Fragment] must be a child or descendant of a Fragment that implements [SubFilterViewModelProvider], + * otherwise this function will throw an [IllegalStateException]. + * + * @param fragment the [Fragment] to get the [SubFilterViewModel] from + * @param tag the [ReaderTag] to get the [SubFilterViewModel] for + * @param savedInstanceState the [Bundle] to pass to the [SubFilterViewModel] when it is created + * @return the [SubFilterViewModel] for the given [ReaderTag] + */ + @JvmStatic + @JvmOverloads + fun getSubFilterViewModelForTag( + fragment: Fragment, + tag: ReaderTag, + savedInstanceState: Bundle? = null + ): SubFilterViewModel { + // traverse the parent fragment hierarchy to find the SubFilterViewModelOwner + var possibleProvider: Fragment? = fragment + while (possibleProvider != null) { + if (possibleProvider is SubFilterViewModelProvider) { + return possibleProvider.getSubFilterViewModelForTag(tag, savedInstanceState) + } + possibleProvider = possibleProvider.parentFragment + } + error("Fragment must be a child or descendant of a Fragment that implements SubFilterViewModelOwner") + } + + /** + * Helper function to get the [SubFilterViewModel] for a given key from a [Fragment]. Note that the [Fragment] + * must be a child or descendant of a Fragment that implements [SubFilterViewModelProvider], otherwise this + * function will throw an [IllegalStateException]. + * + * @param fragment the [Fragment] to get the [SubFilterViewModel] from + * @param key the key to get the [SubFilterViewModel] for + * @return the [SubFilterViewModel] for the given key, or null if the [Fragment] is not a child or descendant + * of a Fragment that implements [SubFilterViewModelProvider] + */ + @JvmStatic + fun getSubFilterViewModelForKey( + fragment: Fragment, + key: String, + ): SubFilterViewModel { + // traverse the parent fragment hierarchy to find the SubFilterViewModelOwner + var possibleProvider: Fragment? = fragment + while (possibleProvider != null) { + if (possibleProvider is SubFilterViewModelProvider) { + return possibleProvider.getSubFilterViewModelForKey(key) + } + possibleProvider = possibleProvider.parentFragment + } + error("Fragment must be a child or descendant of a Fragment that implements SubFilterViewModelOwner") + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubfilterPageFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubfilterPageFragment.kt index 4820df8c4599..120886d85947 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubfilterPageFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubfilterPageFragment.kt @@ -14,7 +14,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentPagerAdapter import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStoreOwner import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint @@ -101,10 +100,7 @@ class SubfilterPageFragment : Fragment() { primaryButton = emptyStateContainer.findViewById(R.id.action_button_primary) secondaryButton = emptyStateContainer.findViewById(R.id.action_button_secondary) - subFilterViewModel = ViewModelProvider( - requireParentFragment().parentFragment as ViewModelStoreOwner, - viewModelFactory - )[subfilterVmKey, SubFilterViewModel::class.java] + subFilterViewModel = SubFilterViewModelProvider.getSubFilterViewModelForKey(this, subfilterVmKey) subFilterViewModel.subFilters.observe(viewLifecycleOwner) { (recyclerView.adapter as? SubfilterListAdapter)?.let { adapter -> diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/tracker/ReaderTracker.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/tracker/ReaderTracker.kt index 1788e4148ef2..5cc3c683b3a8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/tracker/ReaderTracker.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/tracker/ReaderTracker.kt @@ -88,6 +88,7 @@ class ReaderTracker @Inject constructor( ReaderTab.A8C -> analyticsTrackerWrapper.track(AnalyticsTracker.Stat.READER_A8C_SHOWN) ReaderTab.P2 -> analyticsTrackerWrapper.track(AnalyticsTracker.Stat.READER_P2_SHOWN) ReaderTab.CUSTOM -> analyticsTrackerWrapper.track(AnalyticsTracker.Stat.READER_CUSTOM_TAB_SHOWN) + ReaderTab.TAGS_FEED -> analyticsTrackerWrapper.track(AnalyticsTracker.Stat.READER_TAGS_FEED_SHOWN) } appPrefsWrapper.setReaderActiveTab(readerTab) } @@ -404,6 +405,7 @@ class ReaderTracker @Inject constructor( readerTag.isA8C -> "a8c" readerTag.isListTopic -> "list" readerTag.isP2 -> "p2" + readerTag.isTags -> "tags" else -> null }?.let { trackingId -> analyticsTrackerWrapper.track( @@ -467,6 +469,7 @@ class ReaderTracker @Inject constructor( const val SOURCE_SEARCH = "search" const val SOURCE_SITE_PREVIEW = "site_preview" const val SOURCE_TAG_PREVIEW = "tag_preview" + const val SOURCE_TAGS_FEED = "tags_feed" const val SOURCE_POST_DETAIL = "post_detail" const val SOURCE_POST_DETAIL_TOOLBAR = "post_detail_toolbar" const val SOURCE_POST_DETAIL_COMMENT_SNIPPET = "post_detail_comment_snippet" @@ -514,7 +517,8 @@ enum class ReaderTab( SAVED(4, ReaderTracker.SOURCE_SAVED), CUSTOM(5, ReaderTracker.SOURCE_CUSTOM), A8C(6, ReaderTracker.SOURCE_A8C), - P2(7, ReaderTracker.SOURCE_P2); + P2(7, ReaderTracker.SOURCE_P2), + TAGS_FEED(8, ReaderTracker.SOURCE_TAGS_FEED); companion object { fun fromId(id: Int): ReaderTab { @@ -526,6 +530,7 @@ enum class ReaderTab( A8C.id -> A8C P2.id -> P2 CUSTOM.id -> CUSTOM + TAGS_FEED.id -> TAGS_FEED else -> throw RuntimeException("Unexpected ReaderTab id") } } @@ -539,6 +544,7 @@ enum class ReaderTab( readerTag.isDiscover -> DISCOVER readerTag.isA8C -> A8C readerTag.isP2 -> P2 + readerTag.isTags -> TAGS_FEED else -> CUSTOM } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/LoadReaderTabsUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/LoadReaderItemsUseCase.kt similarity index 59% rename from WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/LoadReaderTabsUseCase.kt rename to WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/LoadReaderItemsUseCase.kt index e6d6aea13e5c..0194392aa29d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/LoadReaderTabsUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/LoadReaderItemsUseCase.kt @@ -3,24 +3,29 @@ package org.wordpress.android.ui.reader.usecases import dagger.Reusable import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext +import org.wordpress.android.R import org.wordpress.android.datasets.ReaderTagTable +import org.wordpress.android.models.ReaderTag import org.wordpress.android.models.ReaderTagList +import org.wordpress.android.models.ReaderTagType import org.wordpress.android.models.containsFollowingTag import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.ui.reader.utils.ReaderUtils import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper +import org.wordpress.android.util.StringProvider import javax.inject.Inject import javax.inject.Named /** - * Loads list of tags that should be displayed as tabs in the entry-point Reader screen. + * Loads list of items that should be displayed in the Reader dropdown menu. */ @Reusable -class LoadReaderTabsUseCase @Inject constructor( +class LoadReaderItemsUseCase @Inject constructor( @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, - private val readerUtilsWrapper: ReaderUtilsWrapper + private val readerUtilsWrapper: ReaderUtilsWrapper, + private val stringProvider: StringProvider, ) { - suspend fun loadTabs(): ReaderTagList { + suspend fun load(): ReaderTagList { return withContext(bgDispatcher) { val tagList = ReaderTagTable.getDefaultTags() @@ -28,9 +33,18 @@ class LoadReaderTabsUseCase @Inject constructor( for users who created custom lists in the past.*/ tagList.addAll(ReaderTagTable.getCustomListTags()) - tagList.addAll(ReaderTagTable.getBookmarkTags()) // Add "Saved" tab manually + tagList.addAll(ReaderTagTable.getBookmarkTags()) // Add "Saved" item manually - // Add "Following" tab manually when on self-hosted site + // Add "Tags" item manually + tagList.add(ReaderTag( + "", + stringProvider.getString(R.string.reader_tags_display_name), + stringProvider.getString(R.string.reader_tags_display_name), + "", + ReaderTagType.TAGS + )) + + // Add "Subscriptions" item manually when on self-hosted site if (!tagList.containsFollowingTag()) { tagList.add(readerUtilsWrapper.getDefaultTagFromDbOrCreateInMemory()) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderAnnouncementHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderAnnouncementHelper.kt new file mode 100644 index 000000000000..4dbfc19831af --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderAnnouncementHelper.kt @@ -0,0 +1,57 @@ +package org.wordpress.android.ui.reader.utils + +import dagger.Reusable +import org.wordpress.android.R +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.ui.reader.tracker.ReaderTracker +import org.wordpress.android.ui.reader.views.compose.ReaderAnnouncementCardItemData +import org.wordpress.android.util.config.ReaderAnnouncementCardFeatureConfig +import org.wordpress.android.util.config.ReaderTagsFeedFeatureConfig +import javax.inject.Inject + +@Reusable +class ReaderAnnouncementHelper @Inject constructor( + private val readerAnnouncementCardFeatureConfig: ReaderAnnouncementCardFeatureConfig, + private val readerTagsFeedFeatureConfig: ReaderTagsFeedFeatureConfig, + private val appPrefsWrapper: AppPrefsWrapper, + private val readerTracker: ReaderTracker, +) { + fun hasReaderAnnouncement(): Boolean { + return readerAnnouncementCardFeatureConfig.isEnabled() && appPrefsWrapper.shouldShowReaderAnnouncementCard() + } + + fun getReaderAnnouncementItems(): List { + if (!readerAnnouncementCardFeatureConfig.isEnabled() || !appPrefsWrapper.shouldShowReaderAnnouncementCard()) { + return emptyList() + } + + val items = mutableListOf() + + if (readerTagsFeedFeatureConfig.isEnabled()) { + items.add( + ReaderAnnouncementCardItemData( + iconRes = R.drawable.ic_reader_tag, + titleRes = R.string.reader_announcement_card_tags_stream_title, + descriptionRes = R.string.reader_announcement_card_tags_stream_description, + ) + ) + } + + items.add( + ReaderAnnouncementCardItemData( + iconRes = R.drawable.ic_reader_preferences, + titleRes = R.string.reader_announcement_card_reading_preferences_title, + descriptionRes = R.string.reader_announcement_card_reading_preferences_description, + ) + ) + + return items + } + + fun dismissReaderAnnouncement() { + readerTracker.track(AnalyticsTracker.Stat.READER_ANNOUNCEMENT_CARD_DISMISSED) + appPrefsWrapper.setShouldShowReaderAnnouncementCard(false) + } +} + diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelper.kt index 26e2f876a83c..ba94c014f5c5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelper.kt @@ -10,10 +10,13 @@ import org.wordpress.android.models.ReaderTagList import org.wordpress.android.models.ReaderTagType import org.wordpress.android.ui.compose.components.menu.dropdown.MenuElementData import org.wordpress.android.ui.utils.UiString +import org.wordpress.android.util.config.ReaderTagsFeedFeatureConfig import org.wordpress.android.util.extensions.indexOrNull import javax.inject.Inject -class ReaderTopBarMenuHelper @Inject constructor() { +class ReaderTopBarMenuHelper @Inject constructor( + private val readerTagsFeedFeatureConfig: ReaderTagsFeedFeatureConfig +) { fun createMenu(readerTagsList: ReaderTagList): List { return mutableListOf().apply { readerTagsList.indexOrNull { it.isDiscover }?.let { discoverIndex -> @@ -37,6 +40,11 @@ class ReaderTopBarMenuHelper @Inject constructor() { text = readerTagsList[followedP2sIndex].tagTitle, )) } + if (readerTagsFeedFeatureConfig.isEnabled()) { + readerTagsList.indexOrNull { it.isTags }?.let { tagsIndex -> + add(createTagsItem(getMenuItemIdFromReaderTagIndex(tagsIndex))) + } + } readerTagsList .foldIndexed(SparseArrayCompat()) { index, sparseArray, readerTag -> if (readerTag.tagType == ReaderTagType.CUSTOM_LIST) { @@ -98,6 +106,14 @@ class ReaderTopBarMenuHelper @Inject constructor() { ) } + private fun createTagsItem(id: String): MenuElementData.Item.Single { + return MenuElementData.Item.Single( + id = id, + text = UiString.UiStringRes(R.string.reader_dropdown_menu_tags), + leadingIcon = R.drawable.ic_reader_tags_24dp, + ) + } + private fun createCustomListsItem(customLists: SparseArrayCompat): MenuElementData.Item.SubMenu { val customListsMenuItems = mutableListOf() customLists.forEach { index, readerTag -> diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderUtilsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderUtilsWrapper.kt index 68d27dacaeb9..aacaf2d52eae 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderUtilsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderUtilsWrapper.kt @@ -63,4 +63,10 @@ class ReaderUtilsWrapper @Inject constructor( fun isSelfHosted(authorBlogId: Long) = ReaderUtils.isSelfHosted(authorBlogId) fun getTagFromTagUrl(url: String): String = ReaderUtils.getTagFromTagUrl(url) + + fun getShortLikeLabelText(numLikes: Int): String = + ReaderUtils.getShortLikeLabelText(contextProvider.getContext(), numLikes) + + fun getShortCommentLabelText(numComments: Int): String = + ReaderUtils.getShortCommentLabelText(contextProvider.getContext(), numComments) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderViewModel.kt index 5806d169043a..f06b8c225ef4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderViewModel.kt @@ -25,7 +25,6 @@ import org.wordpress.android.models.ReaderTag import org.wordpress.android.models.ReaderTagList import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.modules.UI_THREAD -import org.wordpress.android.ui.Organization import org.wordpress.android.ui.compose.components.menu.dropdown.MenuElementData import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalOverlayUtil import org.wordpress.android.ui.jetpackoverlay.JetpackOverlayConnectedFeature.READER @@ -39,7 +38,7 @@ import org.wordpress.android.ui.reader.subfilter.SubfilterListItem import org.wordpress.android.ui.reader.tracker.ReaderTab import org.wordpress.android.ui.reader.tracker.ReaderTracker import org.wordpress.android.ui.reader.tracker.ReaderTrackerType.MAIN_READER -import org.wordpress.android.ui.reader.usecases.LoadReaderTabsUseCase +import org.wordpress.android.ui.reader.usecases.LoadReaderItemsUseCase import org.wordpress.android.ui.reader.utils.DateProvider import org.wordpress.android.ui.reader.utils.ReaderTopBarMenuHelper import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel.ReaderUiState.ContentUiState @@ -52,6 +51,7 @@ import org.wordpress.android.util.JetpackBrandingUtils import org.wordpress.android.util.QuickStartUtils import org.wordpress.android.util.SnackbarSequencer import org.wordpress.android.util.UrlUtilsWrapper +import org.wordpress.android.util.config.ReaderTagsFeedFeatureConfig import org.wordpress.android.util.distinct import org.wordpress.android.viewmodel.Event import org.wordpress.android.viewmodel.ScopedViewModel @@ -69,7 +69,7 @@ class ReaderViewModel @Inject constructor( @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, private val appPrefsWrapper: AppPrefsWrapper, private val dateProvider: DateProvider, - private val loadReaderTabsUseCase: LoadReaderTabsUseCase, + private val loadReaderItemsUseCase: LoadReaderItemsUseCase, private val readerTracker: ReaderTracker, private val accountStore: AccountStore, private val quickStartRepository: QuickStartRepository, @@ -79,7 +79,7 @@ class ReaderViewModel @Inject constructor( private val jetpackFeatureRemovalOverlayUtil: JetpackFeatureRemovalOverlayUtil, private val readerTopBarMenuHelper: ReaderTopBarMenuHelper, private val urlUtilsWrapper: UrlUtilsWrapper, - // todo: annnmarie removed this private val getFollowedTagsUseCase: GetFollowedTagsUseCase + private val readerTagsFeedFeatureConfig: ReaderTagsFeedFeatureConfig, ) : ScopedViewModel(mainDispatcher) { private var initialized: Boolean = false private var wasPaused: Boolean = false @@ -140,7 +140,7 @@ class ReaderViewModel @Inject constructor( @JvmOverloads fun loadTabs(savedInstanceState: Bundle? = null) { launch { - val tagList = loadReaderTabsUseCase.loadTabs() + val tagList = loadReaderItemsUseCase.load() if (tagList.isNotEmpty() && readerTagsList != tagList) { updateReaderTagsList(tagList) updateTopBarUiState(savedInstanceState) @@ -225,7 +225,7 @@ class ReaderViewModel @Inject constructor( // Determine if analytics should be bumped either due to tags changed or time elapsed since last bump val now = DateProvider().getCurrentDate().time val shouldBumpAnalytics = event.didChange() - || ( now - appPrefsWrapper.readerAnalyticsCountTagsTimestamp > ONE_HOUR_MILLIS) + || (now - appPrefsWrapper.readerAnalyticsCountTagsTimestamp > ONE_HOUR_MILLIS) if (shouldBumpAnalytics) { readerTracker.trackFollowedTagsCount(event.totalTags) @@ -422,7 +422,9 @@ class ReaderViewModel @Inject constructor( when (item) { is SubfilterListItem.SiteAll -> clearTopBarFilter() is SubfilterListItem.Site -> updateTopBarFilter(item.blog.name - .ifEmpty { urlUtilsWrapper.removeScheme(item.blog.url.ifEmpty { "" }) }, ReaderFilterType.BLOG) + .ifEmpty { urlUtilsWrapper.removeScheme(item.blog.url.ifEmpty { "" }) }, ReaderFilterType.BLOG + ) + is SubfilterListItem.Tag -> updateTopBarFilter(item.tag.tagDisplayName, ReaderFilterType.TAG) else -> Unit // do nothing } @@ -511,11 +513,14 @@ class ReaderViewModel @Inject constructor( } private fun shouldShowBlogsFilter(readerTag: ReaderTag): Boolean { - return readerTag.isFilterable + return readerTag.isFilterable && !readerTag.isTags } private fun shouldShowTagsFilter(readerTag: ReaderTag): Boolean { - return readerTag.isFilterable && readerTag.organization == Organization.NO_ORGANIZATION + val showForFollowedSites = readerTag.isFollowedSites && !readerTagsFeedFeatureConfig.isEnabled() + val showForTags = readerTag.isTags + + return readerTag.isFilterable && (showForFollowedSites || showForTags) } data class TopBarUiState( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapper.kt new file mode 100644 index 000000000000..83c85bb7d9d5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapper.kt @@ -0,0 +1,141 @@ +package org.wordpress.android.ui.reader.viewmodels.tagsfeed + +import org.wordpress.android.models.ReaderPostList +import org.wordpress.android.models.ReaderTag +import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper +import org.wordpress.android.ui.reader.views.compose.tagsfeed.TagsFeedPostItem +import org.wordpress.android.util.DateTimeUtilsWrapper +import org.wordpress.android.util.UrlUtilsWrapper +import javax.inject.Inject + +class ReaderTagsFeedUiStateMapper @Inject constructor( + private val dateTimeUtilsWrapper: DateTimeUtilsWrapper, + private val readerUtilsWrapper: ReaderUtilsWrapper, + private val urlUtilsWrapper: UrlUtilsWrapper, +) { + @Suppress("LongParameterList") + fun mapLoadedTagFeedItem( + tag: ReaderTag, + posts: ReaderPostList, + onTagChipClick: (ReaderTag) -> Unit, + onMoreFromTagClick: (ReaderTag) -> Unit, + onSiteClick: (TagsFeedPostItem) -> Unit, + onPostCardClick: (TagsFeedPostItem) -> Unit, + onPostLikeClick: (TagsFeedPostItem) -> Unit, + onPostMoreMenuClick: (TagsFeedPostItem) -> Unit, + onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit, + ) = ReaderTagsFeedViewModel.TagFeedItem( + tagChip = ReaderTagsFeedViewModel.TagChip( + tag = tag, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + ), + postList = ReaderTagsFeedViewModel.PostList.Loaded( + posts.map { post -> + TagsFeedPostItem( + siteName = post.blogName.takeIf { it.isNotBlank() } + ?: post.blogUrl.let { urlUtilsWrapper.removeScheme(it) }, + postDateLine = dateTimeUtilsWrapper.javaDateToTimeSpan( + post.getDisplayDate(dateTimeUtilsWrapper) + ), + postTitle = post.title, + postExcerpt = post.excerpt, + postImageUrl = post.featuredImage, + postNumberOfLikesText = if (post.numLikes > 0) readerUtilsWrapper.getShortLikeLabelText( + numLikes = post.numLikes + ) else "", + postNumberOfCommentsText = if (post.numReplies > 0) readerUtilsWrapper.getShortCommentLabelText( + numComments = post.numReplies + ) else "", + isPostLiked = post.isLikedByCurrentUser, + isLikeButtonEnabled = true, + postId = post.postId, + blogId = post.blogId, + onSiteClick = onSiteClick, + onPostCardClick = onPostCardClick, + onPostLikeClick = onPostLikeClick, + onPostMoreMenuClick = onPostMoreMenuClick, + ) + } + ), + onItemEnteredView = onItemEnteredView, + ) + + @Suppress("LongParameterList") + fun mapErrorTagFeedItem( + tag: ReaderTag, + errorType: ReaderTagsFeedViewModel.ErrorType, + onTagChipClick: (ReaderTag) -> Unit, + onMoreFromTagClick: (ReaderTag) -> Unit, + onRetryClick: (ReaderTag) -> Unit, + onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit, + ): ReaderTagsFeedViewModel.TagFeedItem = + ReaderTagsFeedViewModel.TagFeedItem( + tagChip = ReaderTagsFeedViewModel.TagChip( + tag = tag, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + ), + postList = ReaderTagsFeedViewModel.PostList.Error( + type = errorType, + onRetryClick = onRetryClick, + ), + onItemEnteredView = onItemEnteredView, + ) + + @Suppress("LongParameterList") + fun mapInitialPostsUiState( + tags: List, + announcementItem: ReaderTagsFeedViewModel.ReaderAnnouncementItem?, + isRefreshing: Boolean, + onTagChipClick: (ReaderTag) -> Unit, + onMoreFromTagClick: (ReaderTag) -> Unit, + onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit, + onRefresh: () -> Unit, + ): ReaderTagsFeedViewModel.UiState.Loaded = + ReaderTagsFeedViewModel.UiState.Loaded( + data = tags.map { tag -> + mapInitialTagFeedItem( + tag = tag, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + onItemEnteredView = onItemEnteredView, + ) + }, + announcementItem = announcementItem, + isRefreshing = isRefreshing, + onRefresh = onRefresh, + ) + + fun mapInitialTagFeedItem( + tag: ReaderTag, + onTagChipClick: (ReaderTag) -> Unit, + onMoreFromTagClick: (ReaderTag) -> Unit, + onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit, + ): ReaderTagsFeedViewModel.TagFeedItem = + ReaderTagsFeedViewModel.TagFeedItem( + tagChip = ReaderTagsFeedViewModel.TagChip( + tag = tag, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + ), + postList = ReaderTagsFeedViewModel.PostList.Initial, + onItemEnteredView = onItemEnteredView, + ) + + fun mapLoadingTagFeedItem( + tag: ReaderTag, + onTagChipClick: (ReaderTag) -> Unit, + onMoreFromTagClick: (ReaderTag) -> Unit, + onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit, + ): ReaderTagsFeedViewModel.TagFeedItem = + ReaderTagsFeedViewModel.TagFeedItem( + tagChip = ReaderTagsFeedViewModel.TagChip( + tag = tag, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + ), + postList = ReaderTagsFeedViewModel.PostList.Loading, + onItemEnteredView = onItemEnteredView, + ) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedViewModel.kt new file mode 100644 index 000000000000..149db5634938 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedViewModel.kt @@ -0,0 +1,635 @@ +package org.wordpress.android.ui.reader.viewmodels.tagsfeed + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import org.wordpress.android.R +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.datasets.wrappers.ReaderPostTableWrapper +import org.wordpress.android.models.ReaderPost +import org.wordpress.android.models.ReaderTag +import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.ui.pages.SnackbarMessageHolder +import org.wordpress.android.ui.reader.ReaderTypes +import org.wordpress.android.ui.reader.discover.FEATURED_IMAGE_HEIGHT_WIDTH_RATION +import org.wordpress.android.ui.reader.discover.PHOTON_WIDTH_QUALITY_RATION +import org.wordpress.android.ui.reader.discover.ReaderCardUiState +import org.wordpress.android.ui.reader.discover.ReaderNavigationEvents +import org.wordpress.android.ui.reader.discover.ReaderPostCardAction +import org.wordpress.android.ui.reader.discover.ReaderPostCardActionType +import org.wordpress.android.ui.reader.discover.ReaderPostCardActionsHandler +import org.wordpress.android.ui.reader.discover.ReaderPostMoreButtonUiStateBuilder +import org.wordpress.android.ui.reader.discover.ReaderPostUiStateBuilder +import org.wordpress.android.ui.reader.exceptions.ReaderPostFetchException +import org.wordpress.android.ui.reader.repository.ReaderPostRepository +import org.wordpress.android.ui.reader.repository.usecases.PostLikeUseCase +import org.wordpress.android.ui.reader.tracker.ReaderTracker +import org.wordpress.android.ui.reader.utils.ReaderAnnouncementHelper +import org.wordpress.android.ui.reader.views.compose.ReaderAnnouncementCardItemData +import org.wordpress.android.ui.reader.views.compose.tagsfeed.TagsFeedPostItem +import org.wordpress.android.util.DisplayUtilsWrapper +import org.wordpress.android.util.NetworkUtilsWrapper +import org.wordpress.android.viewmodel.Event +import org.wordpress.android.viewmodel.ScopedViewModel +import org.wordpress.android.viewmodel.SingleLiveEvent +import javax.inject.Inject +import javax.inject.Named + +@HiltViewModel +class ReaderTagsFeedViewModel @Inject constructor( + @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, + private val readerPostRepository: ReaderPostRepository, + private val readerTagsFeedUiStateMapper: ReaderTagsFeedUiStateMapper, + private val readerPostCardActionsHandler: ReaderPostCardActionsHandler, + private val postLikeUseCase: PostLikeUseCase, + private val readerPostTableWrapper: ReaderPostTableWrapper, + private val readerPostMoreButtonUiStateBuilder: ReaderPostMoreButtonUiStateBuilder, + private val readerPostUiStateBuilder: ReaderPostUiStateBuilder, + private val displayUtilsWrapper: DisplayUtilsWrapper, + private val readerTracker: ReaderTracker, + private val networkUtilsWrapper: NetworkUtilsWrapper, + private val readerAnnouncementHelper: ReaderAnnouncementHelper, +) : ScopedViewModel(bgDispatcher) { + private val _uiStateFlow: MutableStateFlow = MutableStateFlow(UiState.Initial) + val uiStateFlow: StateFlow = _uiStateFlow + + private val _actionEvents = SingleLiveEvent() + val actionEvents: LiveData = _actionEvents + + private val _navigationEvents = MediatorLiveData>() + val navigationEvents: LiveData> = _navigationEvents + + // Unlike the snackbarEvents observable which only expects messages from ReaderPostCardActionsHandler, + // this observable is controlled by this ViewModel. + private val _errorMessageEvents = MediatorLiveData>() + val errorMessageEvents: LiveData> = _errorMessageEvents + + // This observable just expects messages from ReaderPostCardActionsHandler. Nothing is directly triggered + // from this ViewModel. + private val _snackbarEvents = MediatorLiveData>() + val snackbarEvents: LiveData> = _snackbarEvents + + private val _openMoreMenuEvents = SingleLiveEvent() + val openMoreMenuEvents: LiveData = _openMoreMenuEvents + + private var hasInitialized = false + + fun onViewCreated() { + if (!hasInitialized) { + hasInitialized = true + readerPostCardActionsHandler.initScope(viewModelScope) + initNavigationEvents() + initSnackbarEvents() + initUiState() + } + } + + fun onTagsChanged(tags: List) { + return _uiStateFlow.update { currentState -> + when { + tags.isEmpty() -> { + UiState.Empty(::onOpenTagsListClick) + } + + currentState is UiState.Loaded -> { + val currentTags = currentState.data.map { it.tagChip.tag } + if (currentState.isRefreshing) { + readerTagsFeedUiStateMapper.mapInitialPostsUiState( + tags, + getAnnouncementItem(), + false, + ::onTagChipClick, + ::onMoreFromTagClick, + ::onItemEnteredView, + ::onRefresh + ) + } else if (currentTags != tags) { + updateLoadedStateWithTags(currentState, tags) + } else { + currentState + } + } + + else -> { + // Add tags to the list with the posts initial/loading UI + readerTagsFeedUiStateMapper.mapInitialPostsUiState( + tags, + getAnnouncementItem(), + false, + ::onTagChipClick, + ::onMoreFromTagClick, + ::onItemEnteredView, + ::onRefresh + ) + } + } + } + } + + private fun initNavigationEvents() { + _navigationEvents.addSource(readerPostCardActionsHandler.navigationEvents) { event -> + _navigationEvents.value = event + } + } + + private fun initSnackbarEvents() { + _snackbarEvents.addSource(readerPostCardActionsHandler.snackbarEvents) { event -> + _snackbarEvents.value = event + } + } + + private fun initUiState() { + _uiStateFlow.value = if (networkUtilsWrapper.isNetworkAvailable()) { + UiState.Loading + } else { + UiState.NoConnection(::onNoConnectionRetryClick) + } + } + + private fun getAnnouncementItem(): ReaderAnnouncementItem? = + if (readerAnnouncementHelper.hasReaderAnnouncement()) { + ReaderAnnouncementItem( + items = readerAnnouncementHelper.getReaderAnnouncementItems(), + onDoneClicked = ::dismissAnnouncementItem, + ) + } else { + null + } + + private fun dismissAnnouncementItem() { + readerAnnouncementHelper.dismissReaderAnnouncement() + _uiStateFlow.update { + (it as? UiState.Loaded)?.copy(announcementItem = null) ?: it + } + } + + private fun updateLoadedStateWithTags(state: UiState.Loaded, tags: List): UiState.Loaded { + val currentTagsMap = state.data.associateBy { it.tagChip.tag.tagSlug } + val updatedData = tags.map { tag -> + currentTagsMap[tag.tagSlug] ?: readerTagsFeedUiStateMapper.mapInitialTagFeedItem( + tag = tag, + onTagChipClick = ::onTagChipClick, + onMoreFromTagClick = ::onMoreFromTagClick, + onItemEnteredView = ::onItemEnteredView, + ) + } + return state.copy(data = updatedData) + } + + private fun onNoConnectionRetryClick() { + _uiStateFlow.value = UiState.Loading + if (networkUtilsWrapper.isNetworkAvailable()) { + _actionEvents.value = ActionEvent.RefreshTags + } else { + // delay a bit before returning to NoConnection for a better feedback to the user + launch { + delay(NO_CONNECTION_DELAY) + _uiStateFlow.value = UiState.NoConnection(::onNoConnectionRetryClick) + } + } + } + + /** + * Fetch posts for a single tag. This method will emit a new state to [uiStateFlow] for different [UiState]s: + * [UiState.Initial], [UiState.Loaded], [UiState.Loading], [UiState.Empty], but only for the tag being fetched. + */ + @Suppress("SwallowedException") + private suspend fun fetchTag(tag: ReaderTag) { + // Set the tag to loading state + updateTagFeedItem( + readerTagsFeedUiStateMapper.mapLoadingTagFeedItem( + tag = tag, + onTagChipClick = ::onTagChipClick, + onMoreFromTagClick = ::onMoreFromTagClick, + onItemEnteredView = ::onItemEnteredView, + ) + ) + + val updatedItem: TagFeedItem = try { + // Fetch posts for tag + val posts = readerPostRepository.fetchNewerPostsForTag(tag) + if (posts.isNotEmpty()) { + readerTagsFeedUiStateMapper.mapLoadedTagFeedItem( + tag = tag, + posts = posts, + onTagChipClick = ::onTagChipClick, + onMoreFromTagClick = ::onMoreFromTagClick, + onSiteClick = ::onSiteClick, + onPostCardClick = ::onPostCardClick, + onPostLikeClick = ::onPostLikeClick, + onPostMoreMenuClick = ::onPostMoreMenuClick, + onItemEnteredView = ::onItemEnteredView, + ) + } else { + readerTagsFeedUiStateMapper.mapErrorTagFeedItem( + tag = tag, + errorType = ErrorType.NoContent, + onTagChipClick = ::onTagChipClick, + onMoreFromTagClick = ::onMoreFromTagClick, + onRetryClick = ::onRetryClick, + onItemEnteredView = ::onItemEnteredView, + ) + } + } catch (e: ReaderPostFetchException) { + readerTagsFeedUiStateMapper.mapErrorTagFeedItem( + tag = tag, + errorType = ErrorType.Default, + onTagChipClick = ::onTagChipClick, + onMoreFromTagClick = ::onMoreFromTagClick, + onRetryClick = ::onRetryClick, + onItemEnteredView = ::onItemEnteredView, + ) + } + + updateTagFeedItem(updatedItem) + } + + private fun getLoadedData(uiState: UiState): MutableList { + val updatedLoadedData = mutableListOf() + if (uiState is UiState.Loaded) { + updatedLoadedData.addAll(uiState.data) + } + return updatedLoadedData + } + + // Update the UI state for a single feed item, making sure to do it atomically so we don't lose any updates. + private fun updateTagFeedItem(updatedItem: TagFeedItem) { + _uiStateFlow.update { uiState -> + val updatedLoadedData = getLoadedData(uiState) + + // At this point, all tag feed items already exist in the UI. + // We need it's index to update it and keep it in the same place. + updatedLoadedData.indexOfFirst { it.tagChip.tag == updatedItem.tagChip.tag } + .takeIf { it >= 0 } + ?.let { existingIndex -> + // Update item + updatedLoadedData[existingIndex] = updatedItem + } + + (uiState as? UiState.Loaded)?.copy(data = updatedLoadedData) ?: UiState.Loaded( + updatedLoadedData + ) + } + } + + @VisibleForTesting + fun onRefresh() { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _errorMessageEvents.postValue(Event(R.string.no_network_message)) + return + } + + _uiStateFlow.update { + (it as? UiState.Loaded)?.copy(isRefreshing = true) ?: it + } + _actionEvents.value = ActionEvent.RefreshTags + } + + fun onBackFromTagDetails() { + if (!networkUtilsWrapper.isNetworkAvailable()) return + + _actionEvents.value = ActionEvent.RefreshTags + } + + @VisibleForTesting + fun onItemEnteredView(item: TagFeedItem) { + if (item.postList != PostList.Initial) { + // do nothing as it's still loading or already loaded + return + } + + launch { + fetchTag(item.tagChip.tag) + } + } + + @VisibleForTesting + fun onOpenTagsListClick() { + _actionEvents.value = ActionEvent.ShowTagsList + } + + @VisibleForTesting + fun onTagChipClick(readerTag: ReaderTag) { + readerTracker.track(AnalyticsTracker.Stat.READER_TAGS_FEED_HEADER_TAPPED) + _actionEvents.value = ActionEvent.FilterTagPostsFeed(readerTag) + } + + @VisibleForTesting + fun onMoreFromTagClick(readerTag: ReaderTag) { + readerTracker.track(AnalyticsTracker.Stat.READER_TAGS_FEED_MORE_FROM_TAG_TAPPED) + _actionEvents.value = ActionEvent.OpenTagPostList(readerTag) + } + + @VisibleForTesting + fun onRetryClick(readerTag: ReaderTag) { + launch { + fetchTag(readerTag) + } + } + + @VisibleForTesting + fun onSiteClick(postItem: TagsFeedPostItem) { + launch { + findPost(postItem.postId, postItem.blogId)?.let { + _navigationEvents.postValue( + Event( + ReaderNavigationEvents.ShowBlogPreview( + it.blogId, + it.feedId, + it.isFollowedByCurrentUser + ) + ) + ) + } + } + } + + @VisibleForTesting + fun onPostCardClick(postItem: TagsFeedPostItem) { + launch { + findPost(postItem.postId, postItem.blogId)?.let { + readerTracker.trackBlog( + AnalyticsTracker.Stat.READER_POST_CARD_TAPPED, + it.blogId, + it.feedId, + it.isFollowedByCurrentUser, + ReaderTracker.SOURCE_TAGS_FEED, + ) + readerPostCardActionsHandler.handleOnItemClicked( + it, + ReaderTracker.SOURCE_TAGS_FEED + ) + } + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun onPostLikeClick(postItem: TagsFeedPostItem) { + // Immediately update the UI and disable the like button. If the request fails, show error and revert UI state. + // If the request fails or succeeds, the like button is enabled again. + val isPostLikedUpdated = !postItem.isPostLiked + updatePostItemUI( + postItemToUpdate = postItem, + isPostLikedUpdated = isPostLikedUpdated, + isLikeButtonEnabled = false, + ) + + // After updating the like button UI to the intended state and disabling the like button, send a request to the + // like endpoint by using the PostLikeUseCase + likePostRemote(postItem, isPostLikedUpdated) + } + + private fun updatePostItemUI( + postItemToUpdate: TagsFeedPostItem, + isPostLikedUpdated: Boolean, + isLikeButtonEnabled: Boolean, + ) { + val uiState = _uiStateFlow.value as? UiState.Loaded ?: return + // Finds the TagFeedItem associated with the post that should be updated. Return if the item is + // not found. + val tagFeedItemToUpdate = findTagFeedItemToUpdate(uiState, postItemToUpdate) ?: return + + // Finds the index associated with the TagFeedItem to be updated found above. Return if the index is not found. + val tagFeedItemToUpdateIndex = uiState.data.indexOf(tagFeedItemToUpdate) + if (tagFeedItemToUpdateIndex != -1 && tagFeedItemToUpdate.postList is PostList.Loaded) { + // Creates a new post list items collection with the post item updated values + val updatedTagFeedItemPostListItems = getPostListWithUpdatedPostItem( + postList = tagFeedItemToUpdate.postList, + postItemToUpdate = postItemToUpdate, + isPostLikedUpdated = isPostLikedUpdated, + isLikeButtonEnabled = isLikeButtonEnabled, + ) + // Creates a copy of the TagFeedItem with the updated post list items collection + val updatedTagFeedItem = tagFeedItemToUpdate.copy( + postList = tagFeedItemToUpdate.postList.copy( + items = updatedTagFeedItemPostListItems + ) + ) + // Creates a new TagFeedItem collection with the updated TagFeedItem + val updatedUiStateData = mutableListOf().apply { + addAll(uiState.data) + this[tagFeedItemToUpdateIndex] = updatedTagFeedItem + } + // Updates the UI state value with the updated TagFeedItem collection + _uiStateFlow.value = uiState.copy(data = updatedUiStateData) + } + } + + private fun getPostListWithUpdatedPostItem( + postList: PostList.Loaded, + postItemToUpdate: TagsFeedPostItem, + isPostLikedUpdated: Boolean, + isLikeButtonEnabled: Boolean + ) = + postList.items.toMutableList().apply { + val postItemToUpdateIndex = + indexOfFirst { + it.postId == postItemToUpdate.postId && it.blogId == postItemToUpdate.blogId + } + if (postItemToUpdateIndex != -1) { + this[postItemToUpdateIndex] = postItemToUpdate.copy( + isPostLiked = isPostLikedUpdated, + isLikeButtonEnabled = isLikeButtonEnabled, + ) + } + } + + private fun findTagFeedItemToUpdate( + uiState: UiState.Loaded, + postItemToUpdate: TagsFeedPostItem + ) = + uiState.data.firstOrNull { tagFeedItem -> + tagFeedItem.postList is PostList.Loaded && tagFeedItem.postList.items.firstOrNull { + it.postId == postItemToUpdate.postId && it.blogId == postItemToUpdate.blogId + } != null + } + + private fun likePostRemote(postItem: TagsFeedPostItem, isPostLikedUpdated: Boolean) { + launch { + findPost(postItem.postId, postItem.blogId)?.let { post -> + postLikeUseCase.perform( + post, + !post.isLikedByCurrentUser, + ReaderTracker.SOURCE_TAGS_FEED + ).collect { + when (it) { + is PostLikeUseCase.PostLikeState.Success -> { + // Re-enable like button without changing the current post item UI. + updatePostItemUI( + postItemToUpdate = postItem, + isPostLikedUpdated = isPostLikedUpdated, + isLikeButtonEnabled = true, + ) + } + + is PostLikeUseCase.PostLikeState.Failed.NoNetwork -> { + // Revert post item like button UI to the previous state and re-enable like button. + updatePostItemUI( + postItemToUpdate = postItem, + isPostLikedUpdated = !isPostLikedUpdated, + isLikeButtonEnabled = true, + ) + _errorMessageEvents.postValue(Event(R.string.no_network_message)) + } + + is PostLikeUseCase.PostLikeState.Failed.RequestFailed -> { + // Revert post item like button UI to the previous state and re-enable like button. + updatePostItemUI( + postItemToUpdate = postItem, + isPostLikedUpdated = !isPostLikedUpdated, + isLikeButtonEnabled = true, + ) + _errorMessageEvents.postValue(Event(R.string.reader_error_request_failed_title)) + } + + else -> { + // no-op + } + } + } + } + } + } + + private fun onPostMoreMenuClick(postItem: TagsFeedPostItem) { + launch { + findPost(postItem.postId, postItem.blogId)?.let { post -> + val items = readerPostMoreButtonUiStateBuilder.buildMoreMenuItems( + post = post, + includeBookmark = true, + onButtonClicked = ::onMoreMenuButtonClicked, + ) + val photonWidth = + (displayUtilsWrapper.getDisplayPixelWidth() * PHOTON_WIDTH_QUALITY_RATION).toInt() + val photonHeight = (photonWidth * FEATURED_IMAGE_HEIGHT_WIDTH_RATION).toInt() + _openMoreMenuEvents.postValue( + MoreMenuUiState( + readerCardUiState = readerPostUiStateBuilder.mapPostToNewUiState( + source = ReaderTracker.SOURCE_TAGS_FEED, + post = post, + photonWidth = photonWidth, + photonHeight = photonHeight, + postListType = ReaderTypes.ReaderPostListType.TAGS_FEED, + onButtonClicked = { _, _, _ -> }, + onItemClicked = { _, _ -> }, + onItemRendered = {}, + onMoreButtonClicked = {}, + onMoreDismissed = {}, + onVideoOverlayClicked = { _, _ -> }, + onPostHeaderViewClicked = { _, _ -> }, + ), + readerPostCardActions = items, + ) + ) + } + } + } + + private fun onMoreMenuButtonClicked( + postId: Long, + blogId: Long, + type: ReaderPostCardActionType + ) { + launch { + findPost(postId, blogId)?.let { + readerPostCardActionsHandler.onAction( + it, + type, + isBookmarkList = false, + source = ReaderTracker.SOURCE_TAGS_FEED, + ) + } + } + } + + private fun findPost(postId: Long, blogId: Long): ReaderPost? { + return readerPostTableWrapper.getBlogPost( + blogId = blogId, + postId = postId, + excludeTextColumn = true, + ) + } + + sealed class ActionEvent { + data class FilterTagPostsFeed(val readerTag: ReaderTag) : ActionEvent() + + data class OpenTagPostList(val readerTag: ReaderTag) : ActionEvent() + + data object RefreshTags : ActionEvent() + + data object ShowTagsList : ActionEvent() + } + + sealed class UiState { + data object Initial : UiState() + + data class Loaded( + val data: List, + val announcementItem: ReaderAnnouncementItem? = null, + val isRefreshing: Boolean = false, + val onRefresh: () -> Unit = {}, + ) : UiState() + + data object Loading : UiState() + + data class Empty(val onOpenTagsListClick: () -> Unit) : UiState() + + data class NoConnection(val onRetryClick: () -> Unit) : UiState() + } + + data class ReaderAnnouncementItem( + val items: List, + val onDoneClicked: () -> Unit, + ) + + data class TagFeedItem( + val tagChip: TagChip, + val postList: PostList, + private val onItemEnteredView: (TagFeedItem) -> Unit = {}, + ) { + fun onEnteredView() { + onItemEnteredView(this) + } + } + + data class TagChip( + val tag: ReaderTag, + val onTagChipClick: (ReaderTag) -> Unit, + val onMoreFromTagClick: (ReaderTag) -> Unit, + ) + + sealed class PostList { + data object Initial : PostList() + + data class Loaded(val items: List) : PostList() + + data object Loading : PostList() + + data class Error( + val type: ErrorType, + val onRetryClick: (ReaderTag) -> Unit + ) : PostList() + } + + sealed interface ErrorType { + data object Default : ErrorType + + data object NoContent : ErrorType + } + + data class MoreMenuUiState( + val readerCardUiState: ReaderCardUiState.ReaderPostNewUiState, + val readerPostCardActions: List, + ) + + companion object { + private const val NO_CONNECTION_DELAY = 500L + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderAnnouncementCardView.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderAnnouncementCardView.kt new file mode 100644 index 000000000000..6d631b517325 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderAnnouncementCardView.kt @@ -0,0 +1,51 @@ +package org.wordpress.android.ui.reader.views + +import android.content.Context +import android.util.AttributeSet +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.AbstractComposeView +import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.ui.reader.views.compose.ReaderAnnouncementCard +import org.wordpress.android.ui.reader.views.compose.ReaderAnnouncementCardItemData + +class ReaderAnnouncementCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AbstractComposeView(context, attrs, defStyleAttr) { + private val items: MutableState> = mutableStateOf(emptyList()) + + private val onDoneClickListener: MutableState = mutableStateOf(null) + + @Composable + override fun Content() { + AppTheme { + ReaderAnnouncementCard( + items = items.value, + onAnnouncementCardDoneClick = { onDoneClickListener.value?.onDoneClick() } + ) + } + } + + fun setItems(items: List) { + this.items.value = items + } + + fun setOnDoneClickListener(listener: OnDoneClickListener) { + this.onDoneClickListener.value = listener + } + + fun setOnDoneClickListener(block: () -> Unit) { + this.onDoneClickListener.value = object : OnDoneClickListener { + override fun onDoneClick() { + block() + } + } + } + + interface OnDoneClickListener { + fun onDoneClick() + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/ReaderAnnouncementCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/ReaderAnnouncementCard.kt new file mode 100644 index 000000000000..b531c6bd1ce0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/ReaderAnnouncementCard.kt @@ -0,0 +1,170 @@ +package org.wordpress.android.ui.reader.views.compose + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.designsystem.footnote +import org.wordpress.android.ui.compose.theme.AppColor +import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.ui.compose.unit.Margin + +@Composable +fun ReaderAnnouncementCard( + items: List, + onAnnouncementCardDoneClick: () -> Unit, +) { + val primaryColor = if (isSystemInDarkTheme()) AppColor.White else AppColor.Black + val secondaryColor = if (isSystemInDarkTheme()) AppColor.Black else AppColor.White + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Margin.ExtraLarge.value), + verticalArrangement = Arrangement.spacedBy(Margin.ExtraLarge.value), + ) { + // Title + Text( + text = stringResource(R.string.reader_announcement_card_title), + style = MaterialTheme.typography.labelLarge, + color = primaryColor, + ) + // Items + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Margin.ExtraLarge.value) + ) { + items.forEach { + ReaderAnnouncementCardItem(it) + } + } + // Done button + Button( + modifier = Modifier + .fillMaxWidth(), + onClick = { onAnnouncementCardDoneClick() }, + elevation = ButtonDefaults.elevation(0.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = primaryColor, + ), + ) { + Text( + text = stringResource(id = R.string.reader_btn_done), + color = secondaryColor, + style = MaterialTheme.typography.labelLarge, + ) + } + } +} + +@Composable +private fun ReaderAnnouncementCardItem(data: ReaderAnnouncementCardItemData) { + val primaryColor = if (isSystemInDarkTheme()) AppColor.White else AppColor.Black + val secondaryColor = if (isSystemInDarkTheme()) AppColor.Black else AppColor.White + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minWidth = 54.dp, minHeight = 54.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val iconBackgroundColor = primaryColor + Icon( + modifier = Modifier + .padding( + start = Margin.Large.value, + end = Margin.Large.value + ) + .drawBehind { + drawCircle( + color = iconBackgroundColor, + radius = this.size.maxDimension, + ) + }, + painter = painterResource(data.iconRes), + tint = secondaryColor, + contentDescription = null + ) + Column(verticalArrangement = Arrangement.Center) { + Text( + modifier = Modifier.padding( + start = Margin.Large.value, + ), + text = stringResource(data.titleRes), + style = MaterialTheme.typography.labelLarge, + color = primaryColor, + ) + val secondaryElementColor = primaryColor.copy( + alpha = 0.6F + ) + Text( + modifier = Modifier.padding( + start = Margin.Large.value, + ), + text = stringResource(data.descriptionRes), + style = MaterialTheme.typography.footnote, + color = secondaryElementColor, + ) + } + } +} + +data class ReaderAnnouncementCardItemData( + @DrawableRes val iconRes: Int, + @StringRes val titleRes: Int, + @StringRes val descriptionRes: Int, +) + + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ReaderTagsFeedPostListItemPreview() { + AppTheme { + Box( + modifier = Modifier + .fillMaxWidth() + ) { + ReaderAnnouncementCard( + items = listOf( + ReaderAnnouncementCardItemData( + iconRes = R.drawable.ic_wifi_off_24px, + titleRes = R.string.reader_tags_display_name, + descriptionRes = R.string.reader_tags_feed_loading_error_description, + ), + ReaderAnnouncementCardItemData( + iconRes = R.drawable.ic_wifi_off_24px, + titleRes = R.string.reader_tags_display_name, + descriptionRes = R.string.reader_tags_feed_loading_error_description, + ), + ReaderAnnouncementCardItemData( + iconRes = R.drawable.ic_wifi_off_24px, + titleRes = R.string.reader_tags_display_name, + descriptionRes = R.string.reader_tags_feed_loading_error_description, + ), + ), + onAnnouncementCardDoneClick = {}, + ) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeed.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeed.kt new file mode 100644 index 000000000000..7b30f1f0aa0b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeed.kt @@ -0,0 +1,670 @@ +package org.wordpress.android.ui.reader.views.compose.tagsfeed + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +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.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ContentAlpha +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.wordpress.android.R +import org.wordpress.android.models.ReaderTag +import org.wordpress.android.models.ReaderTagType +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.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel.ErrorType +import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel.PostList +import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel.TagChip +import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel.TagFeedItem +import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel.UiState +import org.wordpress.android.ui.reader.views.compose.ReaderAnnouncementCard +import org.wordpress.android.ui.reader.views.compose.filter.ReaderFilterChip +import org.wordpress.android.ui.utils.UiString + +@Composable +fun ReaderTagsFeed(uiState: UiState) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(bottom = 48.dp), + ) { + when (uiState) { + is UiState.Loading -> Loading() + is UiState.Loaded -> Loaded(uiState) + is UiState.Empty -> Empty(uiState) + is UiState.NoConnection -> NoConnection(uiState) + is UiState.Initial -> { + // no-op + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +@Composable +private fun Loaded(uiState: UiState.Loaded) { + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.isRefreshing, + onRefresh = { + uiState.onRefresh() + } + ) + + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(state = pullRefreshState), + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize(), + ) { + uiState.announcementItem?.let { announcementItem -> + item(key = "reader-announcement-card") { + ReaderAnnouncementCard( + items = announcementItem.items, + onAnnouncementCardDoneClick = announcementItem.onDoneClicked, + ) + } + } + + items( + items = uiState.data, + key = { it.tagChip.tag.tagSlug } + ) { item -> + val tagChip = item.tagChip + val postList = item.postList + + LaunchedEffect(item.postList) { + item.onEnteredView() + } + + val backgroundColor = if (isSystemInDarkTheme()) { + AppColor.White.copy(alpha = 0.12F) + } else { + AppColor.Black.copy(alpha = 0.08F) + } + + Column( + modifier = Modifier + .animateItemPlacement() + .fillMaxWidth() + .padding( + top = Margin.Large.value, + bottom = Margin.ExtraExtraMediumLarge.value, + ) + ) { + // Tag chip UI + ReaderFilterChip( + modifier = Modifier.padding( + start = Margin.Large.value, + ), + text = UiString.UiStringText(tagChip.tag.tagTitle), + onClick = { tagChip.onTagChipClick(tagChip.tag) }, + height = 36.dp, + ) + Spacer(modifier = Modifier.height(Margin.Large.value)) + // Posts list UI + when (postList) { + is PostList.Initial, is PostList.Loading -> PostListLoading() + is PostList.Loaded -> PostListLoaded(postList, tagChip, backgroundColor) + is PostList.Error -> PostListError(postList, tagChip, backgroundColor) + } + } + } + } + + PullRefreshIndicator( + refreshing = uiState.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } +} + +@Composable +private fun Loading() { + val fetchingPostsLabel = stringResource(id = R.string.posts_fetching) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .clearAndSetSemantics { + contentDescription = fetchingPostsLabel + }, + userScrollEnabled = false, + ) { + val numberOfLoadingRows = 3 + repeat(numberOfLoadingRows) { + item { + val backgroundColor = if (isSystemInDarkTheme()) { + AppColor.White.copy(alpha = 0.12F) + } else { + AppColor.Black.copy(alpha = 0.08F) + } + Spacer(modifier = Modifier.height(Margin.Large.value)) + Box( + modifier = Modifier + .padding(start = Margin.Large.value) + .width(75.dp) + .height(36.dp) + .clip(shape = RoundedCornerShape(16.dp)) + .background(backgroundColor), + ) + + Spacer(modifier = Modifier.height(Margin.Large.value)) + LazyRow( + modifier = Modifier + .fillMaxWidth(), + userScrollEnabled = false, + horizontalArrangement = Arrangement.spacedBy(Margin.Large.value), + contentPadding = PaddingValues(horizontal = Margin.Large.value), + ) { + items(ReaderTagsFeedComposeUtils.LOADING_POSTS_COUNT) { + ReaderTagsFeedPostListItemLoading() + } + } + } + } + } +} + +@Composable +private fun Empty(uiState: UiState.Empty) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Title + Text( + modifier = Modifier + .padding( + start = Margin.ExtraExtraMediumLarge.value, + end = Margin.ExtraExtraMediumLarge.value, + bottom = Margin.Medium.value, + ), + text = stringResource(id = R.string.reader_discover_empty_title), + textAlign = TextAlign.Center, + fontSize = 20.sp, + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface.copy( + alpha = ContentAlpha.medium, + ), + ) + // Subtitle + Text( + modifier = Modifier + .padding( + start = Margin.ExtraExtraMediumLarge.value, + end = Margin.ExtraExtraMediumLarge.value, + bottom = Margin.Large.value, + ), + text = stringResource(id = R.string.reader_discover_empty_subtitle_follow), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface.copy( + alpha = ContentAlpha.medium, + ), + ) + // Button + Button( + onClick = uiState.onOpenTagsListClick, + modifier = Modifier.padding( + start = Margin.ExtraMediumLarge.value, + end = Margin.ExtraMediumLarge.value, + bottom = Margin.ExtraLarge.value, + ), + contentPadding = PaddingValues( + horizontal = 32.dp, + vertical = 8.dp, + ), + elevation = ButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + ), + colors = ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colors.onPrimary, + backgroundColor = MaterialTheme.colors.onSurface, + ), + ) { + androidx.compose.material.Text( + modifier = Modifier + .align(Alignment.CenterVertically), + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + text = stringResource(id = R.string.reader_discover_empty_button_text), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } +} + +@Composable +fun NoConnection(uiState: UiState.NoConnection) { + val backgroundColor = if (isSystemInDarkTheme()) { + AppColor.White.copy(alpha = 0.12F) + } else { + AppColor.Black.copy(alpha = 0.08F) + } + + Box(modifier = Modifier.fillMaxSize()) { + ErrorMessage( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth(), + backgroundColor = backgroundColor, + titleText = stringResource(R.string.no_connection_error_title), + descriptionText = stringResource(R.string.no_connection_error_description), + actionText = stringResource(R.string.reader_tags_feed_error_retry), + onActionClick = uiState.onRetryClick, + ) + } +} + +@Composable +private fun PostListLoading() { + val loadingLabel = stringResource(id = R.string.loading) + LazyRow( + modifier = Modifier + .fillMaxWidth() + .clearAndSetSemantics { + contentDescription = loadingLabel + }, + userScrollEnabled = false, + horizontalArrangement = Arrangement.spacedBy(Margin.ExtraMediumLarge.value), + contentPadding = PaddingValues( + start = Margin.Large.value, + end = Margin.Large.value + ), + ) { + items(ReaderTagsFeedComposeUtils.LOADING_POSTS_COUNT) { + ReaderTagsFeedPostListItemLoading() + } + } +} + +@Composable +private fun PostListLoaded( + postList: PostList.Loaded, + tagChip: TagChip, + backgroundColor: Color +) { + LazyRow( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Margin.ExtraMediumLarge.value), + contentPadding = PaddingValues( + start = Margin.Large.value, + end = Margin.Large.value + ), + ) { + items( + items = postList.items, + ) { postItem -> + ReaderTagsFeedPostListItem( + item = postItem + ) + } + item { + val baseColor = if (isSystemInDarkTheme()) AppColor.White else AppColor.Black + val primaryElementColor = baseColor.copy( + alpha = 0.87F + ) + Box( + modifier = Modifier + .height(ReaderTagsFeedComposeUtils.PostItemHeight) + .padding( + start = Margin.ExtraLarge.value, + end = Margin.ExtraLarge.value, + ) + ) { + Column( + modifier = Modifier + .align(Alignment.Center) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + onClick = { + tagChip.onMoreFromTagClick(tagChip.tag) + } + ), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier + .drawBehind { + drawCircle( + color = backgroundColor, + radius = this.size.maxDimension + ) + }, + painter = painterResource(R.drawable.ic_arrow_right_white_24dp), + tint = MaterialTheme.colors.onSurface, + contentDescription = null, + ) + Spacer(modifier = Modifier.height(Margin.ExtraMediumLarge.value)) + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource( + id = R.string.reader_tags_feed_see_more_from_tag, + tagChip.tag.tagDisplayName + ), + style = androidx.compose.material3.MaterialTheme.typography.labelLarge, + color = primaryElementColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +private fun PostListError( + postList: PostList.Error, + tagChip: TagChip, + backgroundColor: Color, +) { + val tagName = tagChip.tag.tagDisplayName + val errorMessage = when (postList.type) { + is ErrorType.Default -> stringResource(R.string.reader_tags_feed_loading_error_description) + is ErrorType.NoContent -> stringResource(R.string.reader_tags_feed_no_content_error_description, tagName) + } + + ErrorMessage( + modifier = Modifier + .heightIn(min = ReaderTagsFeedComposeUtils.PostItemHeight) + .fillMaxWidth(), + backgroundColor = backgroundColor, + titleText = stringResource(id = R.string.reader_tags_feed_error_title, tagName), + descriptionText = errorMessage, + actionText = stringResource(R.string.reader_tags_feed_error_retry), + onActionClick = { postList.onRetryClick(tagChip.tag) } + ) +} + +@Composable +private fun ErrorMessage( + backgroundColor: Color, + titleText: String, + descriptionText: String, + actionText: String, + modifier: Modifier = Modifier, + onActionClick: () -> Unit, +) { + Column( + modifier = modifier + .semantics(mergeDescendants = true) {} + .padding(start = 60.dp, end = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + modifier = Modifier + .background( + color = backgroundColor, + shape = CircleShape + ) + .padding(Margin.Medium.value), + painter = painterResource(R.drawable.ic_wifi_off_24px), + tint = MaterialTheme.colors.onSurface, + contentDescription = null + ) + Spacer(modifier = Modifier.height(Margin.ExtraMediumLarge.value)) + Text( + text = titleText, + style = androidx.compose.material3.MaterialTheme.typography.labelLarge, + color = MaterialTheme.colors.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(Margin.Small.value)) + Text( + text = descriptionText, + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + color = if (isSystemInDarkTheme()) { + AppColor.White.copy(alpha = 0.4F) + } else { + AppColor.Black.copy(alpha = 0.4F) + }, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(Margin.Large.value)) + Button( + onClick = onActionClick, + modifier = Modifier + .height(36.dp) + .widthIn(min = 114.dp), + elevation = ButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + ), + colors = ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colors.onPrimary, + backgroundColor = MaterialTheme.colors.onSurface, + ), + shape = RoundedCornerShape(50), + ) { + Text( + modifier = Modifier + .align(Alignment.CenterVertically), + style = androidx.compose.material3.MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.surface, + text = actionText, + ) + } + } +} + +data class TagsFeedPostItem( + val siteName: String, + val postDateLine: String, + val postTitle: String, + val postExcerpt: String, + val postImageUrl: String, + val postNumberOfLikesText: String, + val postNumberOfCommentsText: String, + val isPostLiked: Boolean, + val isLikeButtonEnabled: Boolean, + val postId: Long, + val blogId: Long, + val onSiteClick: (TagsFeedPostItem) -> Unit, + val onPostCardClick: (TagsFeedPostItem) -> Unit, + val onPostLikeClick: (TagsFeedPostItem) -> Unit, + val onPostMoreMenuClick: (TagsFeedPostItem) -> Unit, +) + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ReaderTagsFeedLoaded() { + AppTheme { + val postListLoaded = PostList.Loaded( + listOf( + TagsFeedPostItem( + siteName = "Site Name 1", + postDateLine = "1h", + postTitle = "Post Title 1", + postExcerpt = "Post excerpt 1", + postImageUrl = "postImageUrl1", + postNumberOfLikesText = "15 likes", + postNumberOfCommentsText = "", + isPostLiked = true, + isLikeButtonEnabled = true, + postId = 123L, + blogId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ), + TagsFeedPostItem( + siteName = "Site Name 2", + postDateLine = "2h", + postTitle = "Post Title 2", + postExcerpt = "Post excerpt 2", + postImageUrl = "postImageUrl2", + postNumberOfLikesText = "", + postNumberOfCommentsText = "3 comments", + isPostLiked = true, + isLikeButtonEnabled = true, + postId = 456L, + blogId = 456L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ), + TagsFeedPostItem( + siteName = "Site Name 3", + postDateLine = "3h", + postTitle = "Post Title 3", + postExcerpt = "Post excerpt 3", + postImageUrl = "postImageUrl3", + postNumberOfLikesText = "123 likes", + postNumberOfCommentsText = "9 comments", + isPostLiked = true, + isLikeButtonEnabled = true, + postId = 789L, + blogId = 789L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ), + TagsFeedPostItem( + siteName = "Site Name 4", + postDateLine = "4h", + postTitle = "Post Title 4", + postExcerpt = "Post excerpt 4", + postImageUrl = "postImageUrl4", + postNumberOfLikesText = "1234 likes", + postNumberOfCommentsText = "91 comments", + isPostLiked = true, + isLikeButtonEnabled = true, + postId = 1234L, + blogId = 1234L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ), + TagsFeedPostItem( + siteName = "Site Name 5", + postDateLine = "5h", + postTitle = "Post Title 5", + postExcerpt = "Post excerpt 5", + postImageUrl = "postImageUrl5", + postNumberOfLikesText = "12 likes", + postNumberOfCommentsText = "34 comments", + isPostLiked = true, + isLikeButtonEnabled = true, + postId = 5678L, + blogId = 5678L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ), + ) + ) + ReaderTagsFeed( + uiState = UiState.Loaded( + data = List(4) { + val tagName = "Tag ${it + 1}" + TagFeedItem( + tagChip = TagChip( + tag = ReaderTag( + tagName, + tagName, + tagName, + tagName, + ReaderTagType.TAGS, + ), + onTagChipClick = {}, + onMoreFromTagClick = {}, + ), + postList = postListLoaded + ) + } + ) + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ReaderTagsFeedLoading() { + AppTheme { + ReaderTagsFeed( + uiState = UiState.Loading + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ReaderTagsFeedEmpty() { + AppTheme { + ReaderTagsFeed( + uiState = UiState.Empty( + onOpenTagsListClick = {}, + ) + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedComposeUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedComposeUtils.kt new file mode 100644 index 000000000000..1549deb7ee34 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedComposeUtils.kt @@ -0,0 +1,38 @@ +package org.wordpress.android.ui.reader.views.compose.tagsfeed + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import androidx.compose.ui.unit.sp + +object ReaderTagsFeedComposeUtils { + const val LOADING_POSTS_COUNT = 5 + + const val POST_ITEM_TITLE_MAX_LINES = 2 + val POST_ITEM_IMAGE_SIZE = 64.dp + private val POST_ITEM_HEIGHT = 150.sp // use SP to scale with text size, which is the main content of the item + private val POST_ITEM_MAX_WIDTH = 320.dp + private const val POST_ITEM_WIDTH_PERCENTAGE = 0.8f + + val PostItemHeight: Dp + @Composable + get() { + with(LocalDensity.current) { + return POST_ITEM_HEIGHT.toDp() + } + } + + val PostItemWidth: Dp + @Composable + get() { + val localConfiguration = LocalConfiguration.current + val screenWidth = remember(localConfiguration) { + localConfiguration.screenWidthDp.dp + } + return min((screenWidth * POST_ITEM_WIDTH_PERCENTAGE), POST_ITEM_MAX_WIDTH) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedPostListItem.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedPostListItem.kt new file mode 100644 index 000000000000..d7ed424ef3cd --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedPostListItem.kt @@ -0,0 +1,648 @@ +package org.wordpress.android.ui.reader.views.compose.tagsfeed + +import android.annotation.SuppressLint +import android.content.res.Configuration +import android.view.ViewGroup +import android.widget.ImageView +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.wordpress.android.R +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.util.extensions.getColorResIdFromAttribute +import org.wordpress.android.util.extensions.getDrawableResIdFromAttribute + +private const val CONTENT_TOTAL_LINES = 3 + +@SuppressLint("ResourceType") +@Composable +fun ReaderTagsFeedPostListItem( + item: TagsFeedPostItem, +) = with(item) { + val baseColor = if (isSystemInDarkTheme()) AppColor.White else AppColor.Black + val primaryElementColor = baseColor.copy( + alpha = 0.87F + ) + val secondaryElementColor = baseColor.copy( + alpha = 0.6F + ) + + val hasInteractions = postNumberOfLikesText.isNotBlank() || postNumberOfCommentsText.isNotBlank() + + Column( + modifier = Modifier + .width(ReaderTagsFeedComposeUtils.PostItemWidth) + .height(ReaderTagsFeedComposeUtils.PostItemHeight) + .itemSemanticsModifier(item), + verticalArrangement = Arrangement.spacedBy(Margin.Small.value), + ) { + Row( + modifier = Modifier + .heightIn(min = 24.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + // Site name + Text( + modifier = Modifier + .weight(1f, fill = false) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { onSiteClick(item) }, + ), + text = siteName, + style = MaterialTheme.typography.labelLarge, + color = primaryElementColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // "•" separator + Text( + modifier = Modifier.padding( + horizontal = Margin.Small.value + ), + text = "•", + style = MaterialTheme.typography.bodyMedium, + color = secondaryElementColor, + ) + // Time since it was posted + Text( + text = postDateLine, + style = MaterialTheme.typography.bodyMedium, + color = secondaryElementColor, + ) + } + + // Post content row + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(Margin.Medium.value), + verticalAlignment = Alignment.CenterVertically, + ) { + // Post text content + PostTextContent( + title = postTitle, + excerpt = postExcerpt, + onClick = { onPostCardClick(item) }, + titleColor = baseColor, + excerptColor = primaryElementColor, + modifier = Modifier + .weight(1f), + ) + + // Post image + if (postImageUrl.isNotBlank()) { + PostImage( + imageUrl = postImageUrl, + onClick = { onPostCardClick(item) }, + ) + } + } + + // Likes and comments row + if (hasInteractions) { + val interactionTextStyle = MaterialTheme.typography.bodySmall + + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + // Number of likes + Text( + text = postNumberOfLikesText, + style = interactionTextStyle, + color = secondaryElementColor, + maxLines = 1, + ) + Spacer(Modifier.height(Margin.Medium.value)) + // "•" separator. We should only show it if likes *and* comments text is not empty. + if (postNumberOfLikesText.isNotBlank() && postNumberOfCommentsText.isNotBlank()) { + Text( + modifier = Modifier.padding( + horizontal = Margin.Small.value + ), + text = "•", + style = interactionTextStyle, + color = secondaryElementColor, + ) + } + // Number of comments + Text( + text = postNumberOfCommentsText, + style = interactionTextStyle, + color = secondaryElementColor, + maxLines = 1, + ) + } + } + + // Actions row + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Like action + TextButton( + modifier = Modifier.defaultMinSize(minWidth = 1.dp), + contentPadding = PaddingValues(0.dp), + onClick = { onPostLikeClick(item) }, + enabled = isLikeButtonEnabled, + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource( + if (isPostLiked) { + R.drawable.ic_like_fill_new_24dp + } else { + R.drawable.ic_like_outline_new_24dp + } + ), + contentDescription = stringResource( + if (isPostLiked) { + R.string.mnu_comment_liked + } else { + R.string.reader_label_like + } + ), + tint = if (isPostLiked) { + androidx.compose.material.MaterialTheme.colors.primary + } else { + secondaryElementColor + }, + ) + Text( + text = stringResource(R.string.reader_label_like), + color = if (isPostLiked) { + androidx.compose.material.MaterialTheme.colors.primary + } else { + secondaryElementColor + }, + ) + } + Spacer(Modifier.weight(1f)) + // More menu ("…"). It's an AndroidView because we must have a way to get the view and inflate the existing + // menu, which is a ListPopupWindow and requires an achor. + AndroidView( + factory = { context -> + ImageView(context).apply { + layoutParams = ViewGroup.LayoutParams( + context.resources.getDimensionPixelSize(R.dimen.reader_post_card_new_more_icon), + context.resources.getDimensionPixelSize(R.dimen.reader_post_card_new_more_icon) + ) + setImageResource(R.drawable.ic_more_ellipsis_horizontal_squares) + contentDescription = context.resources.getString(R.string.show_more_desc) + setBackgroundResource( + context.getDrawableResIdFromAttribute( + com.google.android.material.R.attr.selectableItemBackgroundBorderless + ) + ) + setColorFilter( + ContextCompat.getColor( + context, + context.getColorResIdFromAttribute(R.attr.wpColorOnSurfaceMedium) + ) + ) + tag = "${item.blogId}${item.postId}" + setOnClickListener { onPostMoreMenuClick(item) } + } + } + ) + } + } +} + +private fun Modifier.itemSemanticsModifier(item: TagsFeedPostItem): Modifier = composed { + val openPostActionLabel = stringResource(R.string.reader_tags_feed_action_label_open_post) + val openBlogActionLabel = stringResource(R.string.reader_tags_feed_action_label_open_blog) + + val likeStateDescription = if (item.isPostLiked) stringResource(R.string.mnu_comment_liked) else null + val likeActionLabel = if (item.isPostLiked) { + stringResource(R.string.reader_tags_feed_action_label_unlike_post) + } else { + stringResource(R.string.reader_tags_feed_action_label_like_post) + } + + val openMenuActionLabel = stringResource(R.string.reader_tags_feed_action_label_open_menu) + + clearAndSetSemantics { + contentDescription = "${item.siteName}, ${item.postDateLine}, ${item.postTitle}" + customActions = listOf( + CustomAccessibilityAction(openPostActionLabel) { + item.onPostCardClick(item) + true + }, + CustomAccessibilityAction(openBlogActionLabel) { + item.onSiteClick(item) + true + }, + CustomAccessibilityAction(likeActionLabel) { + item.onPostLikeClick(item) + true + }, + CustomAccessibilityAction(openMenuActionLabel) { + item.onPostMoreMenuClick(item) + true + }, + ) + likeStateDescription?.let { stateDescription = it } + } +} + +@Composable +fun PostImage( + imageUrl: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AsyncImage( + modifier = modifier + .size(ReaderTagsFeedComposeUtils.POST_ITEM_IMAGE_SIZE) + .clip(RoundedCornerShape(corner = CornerSize(8.dp))) + .clickable { onClick() }, + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + ) +} + +// Post title and excerpt Column +@Composable +fun PostTextContent( + title: String, + excerpt: String, + titleColor: Color, + excerptColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier, + ) { + val density = LocalDensity.current + val maxWidthPx = with(density) { + maxWidth.toPx().toInt() + } + + val textMeasurer = rememberTextMeasurer() + val titleStyle = MaterialTheme.typography.titleMedium + + val excerptMaxLines = remember(title, titleStyle, maxWidthPx) { + val titleLayoutResult = textMeasurer.measure( + text = title, + style = titleStyle, + maxLines = ReaderTagsFeedComposeUtils.POST_ITEM_TITLE_MAX_LINES, + overflow = TextOverflow.Ellipsis, + constraints = Constraints(maxWidth = maxWidthPx), + ) + + val titleLines = titleLayoutResult.lineCount + CONTENT_TOTAL_LINES - titleLines + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Margin.Small.value), + ) { + // Post title + Text( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ), + text = title, + style = MaterialTheme.typography.titleMedium, + color = titleColor, + maxLines = ReaderTagsFeedComposeUtils.POST_ITEM_TITLE_MAX_LINES, + overflow = TextOverflow.Ellipsis, + ) + + // Post excerpt + Text( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ), + text = excerpt, + style = MaterialTheme.typography.bodySmall, + color = excerptColor, + maxLines = excerptMaxLines, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ReaderTagsFeedPostListItemPreview() { + AppTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + LazyRow( + modifier = Modifier + .fillMaxWidth(), + contentPadding = PaddingValues(24.dp), + horizontalArrangement = Arrangement.spacedBy(Margin.ExtraMediumLarge.value), + ) { + item { + ReaderTagsFeedPostListItem( + TagsFeedPostItem( + siteName = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer" + + " pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postDateLine = "1h", + postTitle = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer " + + "pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postExcerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer " + + "pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl." + + "Lorem ipsum dolor sit amet consectetur adipiscing elit. Integer" + + "pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl." + + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer pellentesque" + + "sapien sed urna fermentum posuere. Vivamus in pretium nisl. Lorem ipsum dolor " + + "sit amet, consectetur adipiscing elit. Integer pellentesque sapien sed urna" + + "fermentum posuere. Vivamus in pretium nisl. Lorem ipsum dolor sit" + + "amet, consectetur adipiscing elit. Integer pellentesque sapien sed urna" + + "fermentum posuere. Vivamus in pretium nisl. Lorem ipsum dolor sit amet," + + "consectetur adipiscing elit. Integer pellentesque sapien sed urna fermentum" + + "posuere. Vivamus in pretium nisl. Lorem ipsum dolor sit amet, consectetur" + + "adipiscing elit. Integer pellentesque sapien sed urna fermentum posuere." + + "Vivamus in pretium nisl.", + postImageUrl = "https://picsum.photos/200/300", + postNumberOfLikesText = "15 likes", + postNumberOfCommentsText = "4 comments", + isPostLiked = true, + isLikeButtonEnabled = true, + blogId = 123L, + postId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ) + ) + } + item { + ReaderTagsFeedPostListItem( + item = TagsFeedPostItem( + siteName = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer pellentesque" + + "sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postDateLine = "1h", + postTitle = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer" + + "pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postExcerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer" + + "pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl. Lorem" + + "ipsum dolor sit amet, " + + "consectetur adipiscing elit. Integer pellentesque sapien sed urna" + + "fermentum posuere. Vivamus in pretium nisl. Lorem ipsum dolor sit amet," + + "consectetur adipiscing elit. Integer pellentesque sapien sed urna fermentum" + + "posuere. Vivamus in pretium nisl. Lorem ipsum dolor sit amet, consectetur" + + "adipiscing elit. Integer pellentesque sapien sed urna fermentum posuere." + + "Vivamus in pretium nisl. Lorem ipsum dolor sit amet, consectetur adipiscing" + + "elit. Integer pellentesque sapien sed urna fermentum posuere. Vivamus in" + + "pretium nisl. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer" + + "pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl. Lorem" + + "ipsum dolor sit amet, consectetur adipiscing elit. Integer pellentesque sapien" + + "sed urna fermentum posuere. Vivamus in pretium nisl.", + postImageUrl = "", + postNumberOfLikesText = "15 likes", + postNumberOfCommentsText = "4 comments", + isPostLiked = true, + isLikeButtonEnabled = true, + blogId = 123L, + postId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ) + ) + } + item { + ReaderTagsFeedPostListItem( + item = TagsFeedPostItem( + siteName = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer pellentesque" + + "sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postDateLine = "1h", + postTitle = "Lorem ipsum dolor sit amet.", + postExcerpt = "Lorem ipsum dolor sit amet.", + postImageUrl = "https://picsum.photos/200/300", + postNumberOfLikesText = "15 likes", + postNumberOfCommentsText = "4 comments", + isPostLiked = true, + isLikeButtonEnabled = true, + blogId = 123L, + postId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ) + ) + } + item { + ReaderTagsFeedPostListItem( + item = TagsFeedPostItem( + siteName = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer pellentesque" + + "sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postDateLine = "1h", + postTitle = "Lorem ipsum dolor sit amet.", + postExcerpt = "Lorem ipsum dolor sit amet.", + postImageUrl = "", + postNumberOfLikesText = "15 likes", + postNumberOfCommentsText = "4 comments", + isPostLiked = true, + isLikeButtonEnabled = true, + blogId = 123L, + postId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ) + ) + } + item { + ReaderTagsFeedPostListItem( + item = TagsFeedPostItem( + siteName = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer pellentesque" + + "sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postDateLine = "1h", + postTitle = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer" + + "pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postExcerpt = "Lorem ipsum dolor sit amet.", + postImageUrl = "https://picsum.photos/200/300", + postNumberOfLikesText = "15 likes", + postNumberOfCommentsText = "4 comments", + isPostLiked = true, + isLikeButtonEnabled = true, + blogId = 123L, + postId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ) + ) + } + item { + ReaderTagsFeedPostListItem( + item = TagsFeedPostItem( + siteName = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer" + + "pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postDateLine = "1h", + postTitle = "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + + "Integer pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postExcerpt = "Lorem ipsum dolor sit amet.", + postImageUrl = "", + postNumberOfLikesText = "15 likes", + postNumberOfCommentsText = "4 comments", + isPostLiked = true, + isLikeButtonEnabled = true, + blogId = 123L, + postId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ) + ) + } + item { + ReaderTagsFeedPostListItem( + item = TagsFeedPostItem( + siteName = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer pellentesque" + + "sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postDateLine = "1h", + postTitle = "Lorem ipsum dolor sit amet.", + postExcerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer" + + "pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl." + + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer pellentesque" + + "sapien sed urna fermentum posuere. Vivamus in pretium nisl. Lorem ipsum dolor" + + "sit amet, consectetur adipiscing elit. Integer pellentesque sapien sed urna" + + "fermentum posuere. Vivamus in pretium nisl. Lorem ipsum dolor sit amet," + + "consectetur adipiscing elit. Integer pellentesque sapien sed urna fermentum" + + "posuere. Vivamus in pretium nisl. Lorem ipsum dolor sit amet, consectetur" + + "adipiscing elit. Integer pellentesque sapien sed urna fermentum posuere." + + "Vivamus in pretium nisl. Lorem ipsum dolor sit amet, consectetur adipiscing" + + "elit. Integer pellentesque sapien sed urna fermentum posuere. Vivamus in" + + "pretium nisl. Lorem ipsum dolor sit amet, consectetur adipiscing elit." + + "Integer pellentesque sapien sed urna fermentum" + + "posuere. Vivamus in pretium nisl.", + postImageUrl = "https://picsum.photos/200/300", + postNumberOfLikesText = "15 likes", + postNumberOfCommentsText = "4 comments", + isPostLiked = true, + isLikeButtonEnabled = true, + blogId = 123L, + postId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ) + ) + } + item { + ReaderTagsFeedPostListItem( + item = TagsFeedPostItem( + siteName = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer pellentesque" + + "sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postDateLine = "1h", + postTitle = "Lorem ipsum dolor sit amet.", + postExcerpt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer" + + "pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl." + + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer pellentesque" + + "sapien sed urna fermentum posuere. Vivamus in pretium nisl. Lorem ipsum dolor" + + "sit amet, consectetur adipiscing elit. Integer pellentesque sapien sed urna" + + "fermentum posuere. Vivamus in pretium nisl. Lorem ipsum dolor sit amet," + + "consectetur adipiscing elit. Integer pellentesque sapien sed urna fermentum" + + "posuere. Vivamus in pretium nisl. Lorem ipsum dolor sit amet, consectetur" + + "adipiscing elit. Integer pellentesque sapien sed urna fermentum posuere." + + "Vivamus in pretium nisl. Lorem ipsum dolor sit amet, consectetur adipiscing" + + "elit. Integer pellentesque sapien sed urna fermentum posuere. Vivamus in" + + "pretium nisl. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer" + + " pellentesque sapien sed urna fermentum posuere. Vivamus in pretium nisl.", + postImageUrl = "", + postNumberOfLikesText = "15 likes", + postNumberOfCommentsText = "4 comments", + isPostLiked = true, + isLikeButtonEnabled = true, + blogId = 123L, + postId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {}, + ) + ) + } + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedPostListItemLoading.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedPostListItemLoading.kt new file mode 100644 index 000000000000..1926a5585d5f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeedPostListItemLoading.kt @@ -0,0 +1,145 @@ +package org.wordpress.android.ui.reader.views.compose.tagsfeed + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.ui.compose.theme.AppColor +import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.ui.compose.unit.Margin + +private val ThinLineHeight = 10.dp +private val ThickLineHeight = 16.dp + +@Composable +fun ReaderTagsFeedPostListItemLoading() { + val contentColor = if (isSystemInDarkTheme()) { + AppColor.White.copy(alpha = 0.12F) + } else { + AppColor.Black.copy(alpha = 0.08F) + } + Column( + modifier = Modifier + .width(ReaderTagsFeedComposeUtils.PostItemWidth) + .height(ReaderTagsFeedComposeUtils.PostItemHeight), + verticalArrangement = Arrangement.SpaceBetween, + ) { + // Site info placeholder + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .width(150.dp) + .height(ThinLineHeight) + .clip(shape = RoundedCornerShape(16.dp)) + .background(contentColor), + ) + } + + // Content row placeholder + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Margin.Medium.value), + verticalAlignment = Alignment.CenterVertically, + ) { + // Post title and excerpt Column placeholder + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.spacedBy(Margin.Medium.value), + ) { + // Title placeholder + Box( + modifier = Modifier + .fillMaxWidth(0.95f) + .height(ThickLineHeight) + .clip(shape = RoundedCornerShape(16.dp)) + .background(contentColor), + ) + Box( + modifier = Modifier + .fillMaxWidth(0.8f) + .height(ThickLineHeight) + .clip(shape = RoundedCornerShape(16.dp)) + .background(contentColor), + ) + } + + // Image placeholder + Box( + modifier = Modifier + .size(ReaderTagsFeedComposeUtils.POST_ITEM_IMAGE_SIZE) + .clip(shape = RoundedCornerShape(8.dp)) + .background(contentColor), + ) + } + + // Likes and comments + actions placeholder + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Margin.MediumLarge.value), + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.6f) + .height(ThinLineHeight) + .clip(shape = RoundedCornerShape(16.dp)) + .background(contentColor), + ) + Box( + modifier = Modifier + .fillMaxWidth(0.5f) + .height(ThinLineHeight) + .clip(shape = RoundedCornerShape(16.dp)) + .background(contentColor), + ) + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ReaderTagsFeedPostListItemLoadingPreview() { + AppTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + LazyRow( + modifier = Modifier + .fillMaxWidth(), + contentPadding = PaddingValues(24.dp), + horizontalArrangement = Arrangement.spacedBy(Margin.ExtraMediumLarge.value), + ) { + items(5) { + ReaderTagsFeedPostListItemLoading() + } + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/insights/usecases/TagsAndCategoriesUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/insights/usecases/TagsAndCategoriesUseCase.kt index c9503dde80e4..86f2f112a70d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/insights/usecases/TagsAndCategoriesUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/insights/usecases/TagsAndCategoriesUseCase.kt @@ -200,7 +200,7 @@ class TagsAndCategoriesUseCase } private fun getIcon(type: String) = - if (type == "tag") R.drawable.ic_tag_white_24dp else R.drawable.ic_folder_white_24dp + if (type == "tag") R.drawable.ic_reader_tag else R.drawable.ic_folder_white_24dp private fun onLinkClick() { analyticsTracker.track(AnalyticsTracker.Stat.STATS_TAGS_AND_CATEGORIES_VIEW_MORE_TAPPED) diff --git a/WordPress/src/main/java/org/wordpress/android/util/config/ReaderAnnouncementCardFeatureConfig.kt b/WordPress/src/main/java/org/wordpress/android/util/config/ReaderAnnouncementCardFeatureConfig.kt new file mode 100644 index 000000000000..84c58133a465 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/config/ReaderAnnouncementCardFeatureConfig.kt @@ -0,0 +1,15 @@ +package org.wordpress.android.util.config + +import org.wordpress.android.BuildConfig +import org.wordpress.android.annotation.Feature +import javax.inject.Inject + +private const val READER_ANNOUNCEMENT_CARD_REMOTE_FIELD = "reader_announcement_card" +@Feature(remoteField = READER_ANNOUNCEMENT_CARD_REMOTE_FIELD, defaultValue = true) +class ReaderAnnouncementCardFeatureConfig @Inject constructor( + appConfig: AppConfig +) : FeatureConfig( + appConfig, + BuildConfig.READER_ANNOUNCEMENT_CARD, + READER_ANNOUNCEMENT_CARD_REMOTE_FIELD, +) diff --git a/WordPress/src/main/java/org/wordpress/android/util/config/ReaderTagsFeedFeatureConfig.kt b/WordPress/src/main/java/org/wordpress/android/util/config/ReaderTagsFeedFeatureConfig.kt new file mode 100644 index 000000000000..acaa667ee3d4 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/config/ReaderTagsFeedFeatureConfig.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 READER_TAGS_FEED_REMOTE_FIELD = "reader_tags_feed" + +@Feature(remoteField = READER_TAGS_FEED_REMOTE_FIELD, defaultValue = true) +class ReaderTagsFeedFeatureConfig @Inject constructor( + appConfig: AppConfig +) : FeatureConfig( + appConfig, + BuildConfig.READER_TAGS_FEED, + READER_TAGS_FEED_REMOTE_FIELD, +) diff --git a/WordPress/src/main/res/drawable/ic_tag_white_24dp.xml b/WordPress/src/main/res/drawable/ic_reader_tag.xml similarity index 100% rename from WordPress/src/main/res/drawable/ic_tag_white_24dp.xml rename to WordPress/src/main/res/drawable/ic_reader_tag.xml diff --git a/WordPress/src/main/res/drawable/ic_reader_tags_24dp.xml b/WordPress/src/main/res/drawable/ic_reader_tags_24dp.xml new file mode 100644 index 000000000000..66e347a7ec51 --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_reader_tags_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/WordPress/src/main/res/drawable/ic_wifi_off_24px.xml b/WordPress/src/main/res/drawable/ic_wifi_off_24px.xml new file mode 100644 index 000000000000..463a7f1120f8 --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_wifi_off_24px.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/WordPress/src/main/res/layout/reader_cardview_announcement.xml b/WordPress/src/main/res/layout/reader_cardview_announcement.xml new file mode 100644 index 000000000000..a225bc9e492b --- /dev/null +++ b/WordPress/src/main/res/layout/reader_cardview_announcement.xml @@ -0,0 +1,4 @@ + + diff --git a/WordPress/src/main/res/layout/reader_tag_feed_fragment_layout.xml b/WordPress/src/main/res/layout/reader_tag_feed_fragment_layout.xml new file mode 100644 index 000000000000..c2b216966121 --- /dev/null +++ b/WordPress/src/main/res/layout/reader_tag_feed_fragment_layout.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/WordPress/src/main/res/values-ar/strings.xml b/WordPress/src/main/res/values-ar/strings.xml index 7179b0d9992f..b370d66b7780 100644 --- a/WordPress/src/main/res/values-ar/strings.xml +++ b/WordPress/src/main/res/values-ar/strings.xml @@ -578,14 +578,14 @@ Language: ar ستنتقل ميزة التنبيهات إلى Jetpack ستنتقل ميزة القارئ إلى تطبيق Jetpack التبديل إلى تطبيق Jetpack الجديد - يتعذر تحميل هذا المحتوى حاليًا. + يتعذر تحميل هذا المحتوى حاليًا. حدث خطأ في أثناء تحميل المطالبات. عذرًا لا توجد مطالبات بعد %d من الإجابات إجابة واحدة ستنتقل ميزة الإحصاءات لديك إلى تطبيق Jetpack - تحقق من اتصالك بالشبكة وحاول مرة أخرى. + تحقق من اتصالك بالشبكة وحاول مرة أخرى. 0 من الإجابات ✓ تم الرد المطالبات diff --git a/WordPress/src/main/res/values-cs/strings.xml b/WordPress/src/main/res/values-cs/strings.xml index 57b8f293e86d..dc706782450c 100644 --- a/WordPress/src/main/res/values-cs/strings.xml +++ b/WordPress/src/main/res/values-cs/strings.xml @@ -148,8 +148,8 @@ Language: cs_CZ Čtečka se přesouvá do aplikace Jetpack Vaše statistiky se přesouvají do aplikace Jetpack Přepněte na novou aplikaci Jetpack - Zkontrolujte připojení k síti a zkuste to znovu. - Momentálně tento obsah nelze načíst + Zkontrolujte připojení k síti a zkuste to znovu. + Momentálně tento obsah nelze načíst Došlo k chybě při načítání to se mi líbí Jejda Zatím žádné výzvy diff --git a/WordPress/src/main/res/values-de/strings.xml b/WordPress/src/main/res/values-de/strings.xml index 125ece1fe55e..d341412c14e1 100644 --- a/WordPress/src/main/res/values-de/strings.xml +++ b/WordPress/src/main/res/values-de/strings.xml @@ -582,8 +582,8 @@ Language: de Der Reader wird in die Jetpack-App verschoben Deine Statistiken werden in die Jetpack-App verschoben Zur neuen Jetpack-App wechseln - Prüfe deine Netzwerkverbindung und versuche es erneut. - Dieser Inhalt kann gerade nicht geladen werden + Prüfe deine Netzwerkverbindung und versuche es erneut. + Dieser Inhalt kann gerade nicht geladen werden Beim Laden der Schreibanregungen ist ein Fehler aufgetreten. Ups Noch keine Schreibanregungen diff --git a/WordPress/src/main/res/values-en-rCA/strings.xml b/WordPress/src/main/res/values-en-rCA/strings.xml index dda4689453ab..73c0b5802f93 100644 --- a/WordPress/src/main/res/values-en-rCA/strings.xml +++ b/WordPress/src/main/res/values-en-rCA/strings.xml @@ -487,8 +487,8 @@ Language: en_CA Stats, Reader, Notifications and other features will soon move to the Jetpack mobile app. There was an error loading prompts. Oops - Check your network connection and try again. - Unable to load this content right now + Check your network connection and try again. + Unable to load this content right now No prompts yet 1 answer %d answers diff --git a/WordPress/src/main/res/values-en-rGB/strings.xml b/WordPress/src/main/res/values-en-rGB/strings.xml index c3f03a616547..aac96f5c19cd 100644 --- a/WordPress/src/main/res/values-en-rGB/strings.xml +++ b/WordPress/src/main/res/values-en-rGB/strings.xml @@ -573,6 +573,16 @@ Language: en_GB Learn more at jetpack.com Remind me later Stats, Reader, Notifications and other Jetpack-powered features will be removed from the WordPress app on %s. + Jetpack features are moving soon. + Notifications are moving to Jetpack + Reader is moving to the Jetpack app + Your stats are moving to the Jetpack app + Switch to the new Jetpack app + Check your network connection and try again. + Unable to load this content right now + There was an error loading prompts. + Oops + No prompts yet Stats, Reader, Notifications and other Jetpack-powered features will be removed from the WordPress app soon. Switch to the Jetpack app Switching is free and only takes a minute. @@ -580,16 +590,6 @@ Language: en_GB %d answers 0 answers 1 answer - Check your network connection and try again. - Jetpack features are moving soon. - No prompts yet - Notifications are moving to Jetpack - Oops - Reader is moving to the Jetpack app - Switch to the new Jetpack app - There was an error loading prompts. - Unable to load this content right now - Your stats are moving to the Jetpack app ✓ Answered close Prompts diff --git a/WordPress/src/main/res/values-es-rCO/strings.xml b/WordPress/src/main/res/values-es-rCO/strings.xml index 768408dcfca4..3a60faaec9f2 100644 --- a/WordPress/src/main/res/values-es-rCO/strings.xml +++ b/WordPress/src/main/res/values-es-rCO/strings.xml @@ -582,8 +582,8 @@ Language: es_CO El lector se está trasladando a la aplicación de Jetpack La estadísticas se están trasladado a la aplicación de Jetpack Cambiar a la nueva aplicación de Jetpack - Comprueba tu conexión a la red e inténtalo de nuevo. - En este momento no se ha podido cargar este contenido + Comprueba tu conexión a la red e inténtalo de nuevo. + En este momento no se ha podido cargar este contenido Se ha producido un error al cargar las indicaciones. ¡Vaya! Todavía no hay sugerencias diff --git a/WordPress/src/main/res/values-es/strings.xml b/WordPress/src/main/res/values-es/strings.xml index 2a5acbe20f77..a8b2c6dd8086 100644 --- a/WordPress/src/main/res/values-es/strings.xml +++ b/WordPress/src/main/res/values-es/strings.xml @@ -579,8 +579,8 @@ Language: es El lector se está trasladando a la aplicación de Jetpack La estadísticas se están trasladado a la aplicación de Jetpack Cambiar a la nueva aplicación de Jetpack - Comprueba tu conexión a la red e inténtalo de nuevo. - En este momento no se ha podido cargar este contenido + Comprueba tu conexión a la red e inténtalo de nuevo. + En este momento no se ha podido cargar este contenido Se ha producido un error al cargar las indicaciones. ¡Vaya! Todavía no hay sugerencias diff --git a/WordPress/src/main/res/values-fr-rCA/strings.xml b/WordPress/src/main/res/values-fr-rCA/strings.xml index da72afb81d98..b8d4668a80d4 100644 --- a/WordPress/src/main/res/values-fr-rCA/strings.xml +++ b/WordPress/src/main/res/values-fr-rCA/strings.xml @@ -572,8 +572,8 @@ Language: fr Le lecteur va être déplacé dans l’application Jetpack Les statistiques vont être déplacées dans l’application Jetpack Passer à la nouvelle application Jetpack - Vérifiez votre connexion réseau et réessayez. - Impossible de charger ce contenu pour le moment + Vérifiez votre connexion réseau et réessayez. + Impossible de charger ce contenu pour le moment Un problème est survenu lors du chargement des incitations. Oups Pas encore d’incitation diff --git a/WordPress/src/main/res/values-fr/strings.xml b/WordPress/src/main/res/values-fr/strings.xml index da72afb81d98..b8d4668a80d4 100644 --- a/WordPress/src/main/res/values-fr/strings.xml +++ b/WordPress/src/main/res/values-fr/strings.xml @@ -572,8 +572,8 @@ Language: fr Le lecteur va être déplacé dans l’application Jetpack Les statistiques vont être déplacées dans l’application Jetpack Passer à la nouvelle application Jetpack - Vérifiez votre connexion réseau et réessayez. - Impossible de charger ce contenu pour le moment + Vérifiez votre connexion réseau et réessayez. + Impossible de charger ce contenu pour le moment Un problème est survenu lors du chargement des incitations. Oups Pas encore d’incitation diff --git a/WordPress/src/main/res/values-gl/strings.xml b/WordPress/src/main/res/values-gl/strings.xml index 08c59e302fd4..6429e9b386f7 100644 --- a/WordPress/src/main/res/values-gl/strings.xml +++ b/WordPress/src/main/res/values-gl/strings.xml @@ -584,9 +584,9 @@ Language: gl_ES Os avisos estanse trasladando á aplicación de Jetpack O lector estase trasladando á aplicación de Jetpack Produciuse un erro ao cargar as indicacións. - Neste momento non se puido cargar este contido + Neste momento non se puido cargar este contido A estatísticas estanse trasladado á aplicación de Jetpack - Comproba a túa conexión á rede e inténtao de novo. + Comproba a túa conexión á rede e inténtao de novo. Peticións ✓ Respondido pechar diff --git a/WordPress/src/main/res/values-he/strings.xml b/WordPress/src/main/res/values-he/strings.xml index dccbdd35492e..405e6d661bc9 100644 --- a/WordPress/src/main/res/values-he/strings.xml +++ b/WordPress/src/main/res/values-he/strings.xml @@ -572,8 +572,8 @@ Language: he_IL הכלי Reader עובר לאפליקציה של Jetpack הנתונים הסטטיסטיים שלך עוברים לאפליקציה של Jetpack לעבור לאפליקציה החדשה של Jetpack - יש לבדוק את החיבור לרשת ולנסות שוב. - אין אפשרות לטעון את התוכן כרגע + יש לבדוק את החיבור לרשת ולנסות שוב. + אין אפשרות לטעון את התוכן כרגע אירעה שגיאה בטעינת ההצעות. אופס עדיין אין הצעות diff --git a/WordPress/src/main/res/values-id/strings.xml b/WordPress/src/main/res/values-id/strings.xml index c26a8540dd86..c6642f12e326 100644 --- a/WordPress/src/main/res/values-id/strings.xml +++ b/WordPress/src/main/res/values-id/strings.xml @@ -579,8 +579,8 @@ Language: id Reader akan berpindah ke aplikasi Jetpack Statistik Anda akan berpindah ke aplikasi Jetpack Beralih ke aplikasi Jetpack baru - Periksa koneksi internet Anda dan coba lagi. - Tidak dapat memuat konten ini sekarang + Periksa koneksi internet Anda dan coba lagi. + Tidak dapat memuat konten ini sekarang Terjadi eror saat memuat prompt. Waduh Belum ada prompt diff --git a/WordPress/src/main/res/values-it/strings.xml b/WordPress/src/main/res/values-it/strings.xml index b1acbe0eb984..b0a85ca574a9 100644 --- a/WordPress/src/main/res/values-it/strings.xml +++ b/WordPress/src/main/res/values-it/strings.xml @@ -574,8 +574,8 @@ Language: it Le notifiche si stanno spostando sull\'app Jetpack Le tue statistiche si stanno spostando sull\'app Jetpack Passa alla nuova app Jetpack - Controlla la connessione di rete e riprova. - Impossibile caricare questo contenuto al momento + Controlla la connessione di rete e riprova. + Impossibile caricare questo contenuto al momento Si è verificato un errore durante il caricamento delle richieste. Ops Ancora nessuna richiesta diff --git a/WordPress/src/main/res/values-ja/strings.xml b/WordPress/src/main/res/values-ja/strings.xml index 2033be22b2b6..1fe65db69d93 100644 --- a/WordPress/src/main/res/values-ja/strings.xml +++ b/WordPress/src/main/res/values-ja/strings.xml @@ -567,8 +567,8 @@ Language: ja_JP Reader は Jetpack アプリに移動しています 統計は Jetpack アプリに移動しています 新しい Jetpack アプリに切り替える - ネットワーク接続を確認して、もう一度お試しください。 - 現在このページを読み込めません + ネットワーク接続を確認して、もう一度お試しください。 + 現在このページを読み込めません プロンプトの読み込みでエラーが発生しました。 エラーです プロンプトはまだありません diff --git a/WordPress/src/main/res/values-ko/strings.xml b/WordPress/src/main/res/values-ko/strings.xml index ae5ae67a771e..fcbe1fbb5e31 100644 --- a/WordPress/src/main/res/values-ko/strings.xml +++ b/WordPress/src/main/res/values-ko/strings.xml @@ -579,8 +579,8 @@ Language: ko_KR 젯팩 앱으로 리더 이동 중 젯팩 앱으로 통계 이동 중 새 젯팩 앱으로 전환 - 네트워크 연결을 확인하고 다시 시도하세요. - 지금 이 콘텐츠를 로드할 수 없습니다. + 네트워크 연결을 확인하고 다시 시도하세요. + 지금 이 콘텐츠를 로드할 수 없습니다. 프롬프트 로드 중 오류가 발생했습니다. 죄송합니다. 아직 프롬프트 없음 diff --git a/WordPress/src/main/res/values-lv/strings.xml b/WordPress/src/main/res/values-lv/strings.xml index 65a4ea1da3f2..7da9dd32f15f 100644 --- a/WordPress/src/main/res/values-lv/strings.xml +++ b/WordPress/src/main/res/values-lv/strings.xml @@ -283,8 +283,8 @@ Language: lv Lasītājs pāriet uz Jetpack lietotni Jūsu statistika tiek pārvietota uz Jetpack lietotni Pārslēdzieties uz jauno Jetpack lietotni - Pārbaudiet tīkla savienojumu un mēģiniet vēlreiz. - Pašlaik nevar ielādēt šo saturu + Pārbaudiet tīkla savienojumu un mēģiniet vēlreiz. + Pašlaik nevar ielādēt šo saturu Ielādējot uzvednes, radās kļūda. Hmm… Vēl nav uzvedņu diff --git a/WordPress/src/main/res/values-nl/strings.xml b/WordPress/src/main/res/values-nl/strings.xml index a355b9875138..0692329a20e1 100644 --- a/WordPress/src/main/res/values-nl/strings.xml +++ b/WordPress/src/main/res/values-nl/strings.xml @@ -582,8 +582,8 @@ Language: nl Reader zal naar de Jetpack-app worden verplaatst Je statistieken zullen naar de Jetpack-app worden verplaatst Schakel over naar de nieuwe Jetpack-app - Controleer je netwerkverbinding en probeer het nogmaals. - Deze inhoud kan momenteel niet geladen worden + Controleer je netwerkverbinding en probeer het nogmaals. + Deze inhoud kan momenteel niet geladen worden Er is een fout opgetreden bij het laden van opdrachten. Oeps Er zijn nog geen opdrachten diff --git a/WordPress/src/main/res/values-pt-rBR/strings.xml b/WordPress/src/main/res/values-pt-rBR/strings.xml index 53d9a708ca80..b6923e50a2af 100644 --- a/WordPress/src/main/res/values-pt-rBR/strings.xml +++ b/WordPress/src/main/res/values-pt-rBR/strings.xml @@ -308,8 +308,8 @@ Language: pt_BR O Leitor está migrando para o aplicativo Jetpack Suas estatísticas estão migrando para o aplicativo Jetpack Mudar para o novo aplicativo do Jetpack - Verifique sua conexão de rede e tente novamente. - Não foi possível carregar esse conteúdo no momento + Verifique sua conexão de rede e tente novamente. + Não foi possível carregar esse conteúdo no momento Ocorreu um erro ao carregar as sugestões. Opa Nenhuma sugestão ainda diff --git a/WordPress/src/main/res/values-ro/strings.xml b/WordPress/src/main/res/values-ro/strings.xml index 348d115bc012..7fba41ca7c50 100644 --- a/WordPress/src/main/res/values-ro/strings.xml +++ b/WordPress/src/main/res/values-ro/strings.xml @@ -582,8 +582,8 @@ Language: ro Cititorul a fost mutat în aplicația Jetpack Statisticile se mută în aplicația Jetpack Comută la noua aplicație Jetpack - Verifică conexiunea rețelei și încearcă din nou. - Nu pot să încarc acest conținut chiar acum + Verifică conexiunea rețelei și încearcă din nou. + Nu pot să încarc acest conținut chiar acum A fost o eroare la încărcarea îndemnurilor. Hopa Niciun îndemn încă diff --git a/WordPress/src/main/res/values-ru/strings.xml b/WordPress/src/main/res/values-ru/strings.xml index 7c474364068d..59f5259d24d9 100644 --- a/WordPress/src/main/res/values-ru/strings.xml +++ b/WordPress/src/main/res/values-ru/strings.xml @@ -582,8 +582,8 @@ Language: ru Чтиво переезжает в приложение Jetpack Статистика переезжает в приложение Jetpack Перейти в новое приложение Jetpack - Проверьте ваше подключение к сети и попробуйте снова. - Сейчас невозможно загрузить это содержимое + Проверьте ваше подключение к сети и попробуйте снова. + Сейчас невозможно загрузить это содержимое При загрузке подсказок возникла ошибка. Ой! Пока нет подсказок diff --git a/WordPress/src/main/res/values-sq/strings.xml b/WordPress/src/main/res/values-sq/strings.xml index 80efce28b867..ac8eebe13f3a 100644 --- a/WordPress/src/main/res/values-sq/strings.xml +++ b/WordPress/src/main/res/values-sq/strings.xml @@ -516,8 +516,8 @@ Language: sq_AL Lexuesi po kalohet te aplikacioni Jetpack Statistikat tuaja po kalojnë te aplikacioni Jetpack Kaloni në aplikacionin e ri Jetpack - Kontrolloni lidhjen tuaj në rrjet dhe riprovoni. - S’arrihet të ngarkohet kjo lëndë këtë çast + Kontrolloni lidhjen tuaj në rrjet dhe riprovoni. + S’arrihet të ngarkohet kjo lëndë këtë çast Pati një gabim në ngarkim cytjesh. Hëm Ende pa cytje diff --git a/WordPress/src/main/res/values-sv/strings.xml b/WordPress/src/main/res/values-sv/strings.xml index 481b16281a8a..a8e8ba7ff3f9 100644 --- a/WordPress/src/main/res/values-sv/strings.xml +++ b/WordPress/src/main/res/values-sv/strings.xml @@ -582,8 +582,8 @@ Language: sv_SE Läsaren flyttar till Jetpack-appen Din statistik flyttas till Jetpack-appen Byt till den nya Jetpack-appen - Kontrollera din nätverksanslutning och försök igen. - Det går inte att ladda detta innehåll just nu + Kontrollera din nätverksanslutning och försök igen. + Det går inte att ladda detta innehåll just nu Det var ett problem att ladda in förslag. Hoppsan Inga förslag än diff --git a/WordPress/src/main/res/values-tr/strings.xml b/WordPress/src/main/res/values-tr/strings.xml index aaa074a6da0d..bd08e00bf72e 100644 --- a/WordPress/src/main/res/values-tr/strings.xml +++ b/WordPress/src/main/res/values-tr/strings.xml @@ -576,8 +576,8 @@ Language: tr Okuyucu, Jetpack uygulamasına taşınıyor İstatistikleriniz Jetpack uygulamasına taşınıyor Yeni Jetpack uygulamasına geçiş yapın - Ağ bağlantınızı denetleyip yeniden deneyin. - Bu içerik şu an yüklenemiyor + Ağ bağlantınızı denetleyip yeniden deneyin. + Bu içerik şu an yüklenemiyor İstemler yüklenirken bir sorun çıktı. Eyvah Henüz bir istem yok diff --git a/WordPress/src/main/res/values-zh-rCN/strings.xml b/WordPress/src/main/res/values-zh-rCN/strings.xml index 35a5485fe368..c36991cb5778 100644 --- a/WordPress/src/main/res/values-zh-rCN/strings.xml +++ b/WordPress/src/main/res/values-zh-rCN/strings.xml @@ -570,8 +570,8 @@ Language: zh_CN 阅读器即将移至 Jetpack 应用 统计信息即将移至 Jetpack 应用 切换到新版 Jetpack 应用 - 请检查您的网络连接,然后重试。 - 现在无法加载此内容 + 请检查您的网络连接,然后重试。 + 现在无法加载此内容 加载提示时出错。 糟糕 尚无提示 diff --git a/WordPress/src/main/res/values-zh-rHK/strings.xml b/WordPress/src/main/res/values-zh-rHK/strings.xml index bbd3ed20456d..be345f681a93 100644 --- a/WordPress/src/main/res/values-zh-rHK/strings.xml +++ b/WordPress/src/main/res/values-zh-rHK/strings.xml @@ -572,8 +572,8 @@ Language: zh_TW 「閱讀器」將轉移至 Jetpack 應用程式 你的統計資料將轉移至 Jetpack 應用程式 切換至全新 Jetpack 應用程式 - 請檢查你的網路連線並再試一次。 - 目前無法載入此內容 + 請檢查你的網路連線並再試一次。 + 目前無法載入此內容 載入提示時發生錯誤。 糟糕 尚無提示 diff --git a/WordPress/src/main/res/values-zh-rTW/strings.xml b/WordPress/src/main/res/values-zh-rTW/strings.xml index bbd3ed20456d..be345f681a93 100644 --- a/WordPress/src/main/res/values-zh-rTW/strings.xml +++ b/WordPress/src/main/res/values-zh-rTW/strings.xml @@ -572,8 +572,8 @@ Language: zh_TW 「閱讀器」將轉移至 Jetpack 應用程式 你的統計資料將轉移至 Jetpack 應用程式 切換至全新 Jetpack 應用程式 - 請檢查你的網路連線並再試一次。 - 目前無法載入此內容 + 請檢查你的網路連線並再試一次。 + 目前無法載入此內容 載入提示時發生錯誤。 糟糕 尚無提示 diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 06aae927c4c1..fc1aad108df8 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -122,6 +122,9 @@ <Experimental> + Unable to load this content right now + Check your network connection and try again. + %d selected @@ -1710,6 +1713,7 @@ Saved Liked Automattic + Your Tags Lists 0 Blogs 1 Blog @@ -1717,6 +1721,11 @@ 0 Tags 1 Tag %d Tags + New in Reader + Tags stream + Tap the dropdown at the top and select Tags to access streams from your followed tags. + Reading Preferences + Choose colors and fonts that suit you. When you’re reading a post tap the AA icon at the top of the screen. Reading Preferences reading,colors,fonts @@ -2139,6 +2148,7 @@ Discover Likes Subscribed + Tags now @@ -2336,6 +2346,18 @@ Save Posts for Later Save this post, and come back to read it whenever you\'d like. It will only be available on this device — saved posts don\'t sync to your other devices. + + More from %s + No posts found for %s + We couldn\'t load posts from this tag right now + We couldn\'t find any posts tagged %s right now + Retry + open post + open blog + like post + remove post like + open menu + No connection @@ -4309,8 +4331,6 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> No prompts yet Oops There was an error loading prompts. - Unable to load this content right now - Check your network connection and try again. Content diff --git a/WordPress/src/test/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProviderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProviderTest.kt index 73250ba0c7f5..5b1fc3546c74 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProviderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProviderTest.kt @@ -10,7 +10,7 @@ import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.models.ReaderTag import org.wordpress.android.models.ReaderTagType -import org.wordpress.android.ui.reader.services.post.ReaderPostLogic +import org.wordpress.android.ui.reader.repository.ReaderPostRepository import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper import kotlin.test.assertEquals @@ -47,7 +47,7 @@ class BloggingPromptsPostTagProviderTest : BaseUnitTest() { BLOGGING_PROMPT_ID_TAG, BLOGGING_PROMPT_ID_TAG, BLOGGING_PROMPT_ID_TAG, - ReaderPostLogic.formatFullEndpointForTag(BLOGGING_PROMPT_ID_TAG), + ReaderPostRepository.formatFullEndpointForTag(BLOGGING_PROMPT_ID_TAG), ReaderTagType.FOLLOWED, ) val actual = tagProvider.promptSearchReaderTag("valid-url") @@ -61,7 +61,7 @@ class BloggingPromptsPostTagProviderTest : BaseUnitTest() { BLOGGING_PROMPT_TAG, BLOGGING_PROMPT_TAG, BLOGGING_PROMPT_TAG, - ReaderPostLogic.formatFullEndpointForTag(BLOGGING_PROMPT_TAG), + ReaderPostRepository.formatFullEndpointForTag(BLOGGING_PROMPT_TAG), ReaderTagType.FOLLOWED, ) val actual = tagProvider.promptSearchReaderTag("invalid-url") diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/ReaderTestUtils.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/ReaderTestUtils.kt new file mode 100644 index 000000000000..99c7fe6e3ac5 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/ReaderTestUtils.kt @@ -0,0 +1,17 @@ +package org.wordpress.android.ui.reader + +import org.wordpress.android.models.ReaderTag +import org.wordpress.android.models.ReaderTagType + +object ReaderTestUtils { + fun createTag( + slug: String, + type: ReaderTagType = ReaderTagType.FOLLOWED, + ): ReaderTag = ReaderTag( + slug, + slug, + slug, + "endpoint/$slug", + type, + ) +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/ReaderTrackerTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/ReaderTrackerTest.kt index c45df778107b..71221349351d 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/reader/ReaderTrackerTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/ReaderTrackerTest.kt @@ -325,6 +325,23 @@ class ReaderTrackerTest { ) } + @Test + fun `Should track dropdown menu tags feed item tapped`() { + tracker.trackDropdownMenuItemTapped( + ReaderTag( + "slug", + "displayName", + "title", + null, + ReaderTagType.TAGS, + ) + ) + verify(analyticsTrackerWrapper).track( + stat = AnalyticsTracker.Stat.READER_DROPDOWN_MENU_ITEM_TAPPED, + properties = mapOf("id" to "tags"), + ) + } + @Test fun `Should track post with reading preferences returned from ReadingPreferencesTracker`() { val post = ReaderPost() diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModelTest.kt index 064409261b3c..06995bd7a6b1 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModelTest.kt @@ -54,6 +54,7 @@ import org.wordpress.android.ui.reader.discover.ReaderPostCardActionType.LIKE import org.wordpress.android.ui.reader.discover.ReaderPostCardActionType.REBLOG import org.wordpress.android.ui.reader.discover.interests.TagUiState import org.wordpress.android.ui.reader.reblog.ReblogUseCase +import org.wordpress.android.ui.reader.utils.ReaderAnnouncementHelper import org.wordpress.android.ui.reader.repository.ReaderDiscoverCommunication import org.wordpress.android.ui.reader.repository.ReaderDiscoverCommunication.Error.NetworkUnavailable import org.wordpress.android.ui.reader.repository.ReaderDiscoverCommunication.Started @@ -137,6 +138,9 @@ class ReaderDiscoverViewModelTest : BaseUnitTest() { @Mock private lateinit var readerImprovementsFeatureConfig: ReaderImprovementsFeatureConfig + @Mock + private lateinit var mReaderAnnouncementHelper: ReaderAnnouncementHelper + private val fakeDiscoverFeed = ReactiveMutableLiveData() private val fakeCommunicationChannel = MutableLiveData>() private val fakeNavigationFeed = MutableLiveData>() @@ -160,6 +164,7 @@ class ReaderDiscoverViewModelTest : BaseUnitTest() { displayUtilsWrapper, getFollowedTagsUseCase, readerImprovementsFeatureConfig, + mReaderAnnouncementHelper, testDispatcher(), testDispatcher() ) @@ -400,6 +405,55 @@ class ReaderDiscoverViewModelTest : BaseUnitTest() { assertThat(contentUiState.cards.first()).isInstanceOf(ReaderPostNewUiState::class.java) } + @Test + fun `if Announcement does not exist then ReaderAnnouncementCardUiState will not be present`() = test { + // Arrange + whenever(mReaderAnnouncementHelper.hasReaderAnnouncement()).thenReturn(false) + val uiStates = init(autoUpdateFeed = false).uiStates + // Act + fakeDiscoverFeed.value = createDummyReaderCardsList() // mock finished loading + // Assert + val contentUiState = uiStates.last() as ContentUiState + assertThat(contentUiState.cards.first()) + .isNotInstanceOf(ReaderCardUiState.ReaderAnnouncementCardUiState::class.java) + } + + @Test + fun `if Announcement exists then ReaderAnnouncementCardUiState will be present`() = test { + // Arrange + whenever(mReaderAnnouncementHelper.hasReaderAnnouncement()).thenReturn(true) + whenever(mReaderAnnouncementHelper.getReaderAnnouncementItems()).thenReturn(mock()) + val uiStates = init(autoUpdateFeed = false).uiStates + // Act + fakeDiscoverFeed.value = createDummyReaderCardsList() // mock finished loading + // Assert + val contentUiState = uiStates.last() as ContentUiState + assertThat(contentUiState.cards.first()) + .isInstanceOf(ReaderCardUiState.ReaderAnnouncementCardUiState::class.java) + } + + @Test + fun `clicking done on ReaderAnnouncementCardUiState dismisses and updates the ContentUiState`() = test { + // Arrange + whenever(mReaderAnnouncementHelper.hasReaderAnnouncement()).thenReturn(true) + whenever(mReaderAnnouncementHelper.getReaderAnnouncementItems()).thenReturn(mock()) + val uiStates = init(autoUpdateFeed = false).uiStates + + fakeDiscoverFeed.value = createDummyReaderCardsList() // mock finished loading + val contentUiState = uiStates.last() as ContentUiState + val announcementCard = contentUiState.cards.first() as ReaderCardUiState.ReaderAnnouncementCardUiState + + // Act + announcementCard.onDoneClick() + + // Assert + verify(mReaderAnnouncementHelper).dismissReaderAnnouncement() + + val newContentUiState = uiStates.last() as ContentUiState + assertThat(newContentUiState.cards.first()) + .isNotInstanceOf(ReaderCardUiState.ReaderAnnouncementCardUiState::class.java) + } + @Test fun `Discover data provider is started when the vm is started`() = test { // Act diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogicFactoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogicFactoryTest.kt new file mode 100644 index 000000000000..dea73350f024 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogicFactoryTest.kt @@ -0,0 +1,32 @@ +package org.wordpress.android.ui.reader.services.post + +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.wordpress.android.ui.reader.repository.ReaderPostRepository +import org.wordpress.android.ui.reader.services.ServiceCompletionListener + +@RunWith(MockitoJUnitRunner::class) +class ReaderPostLogicFactoryTest { + @Mock + lateinit var readerPostRepository: ReaderPostRepository + + private lateinit var factory: ReaderPostLogicFactory + + @Before + fun setUp() { + factory = ReaderPostLogicFactory(readerPostRepository) + } + + @Test + fun `create should return a PostLogic instance`() { + val listener = ServiceCompletionListener { + // no-op + } + val logic = factory.create(listener) + assertThat(logic).isInstanceOf(ReaderPostLogic::class.java) + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/sources/ReaderPostLocalSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/sources/ReaderPostLocalSourceTest.kt new file mode 100644 index 000000000000..34e555cf219e --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/sources/ReaderPostLocalSourceTest.kt @@ -0,0 +1,327 @@ +package org.wordpress.android.ui.reader.sources + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.only +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.datasets.wrappers.ReaderPostTableWrapper +import org.wordpress.android.models.ReaderPostList +import org.wordpress.android.models.ReaderTag +import org.wordpress.android.models.ReaderTagType +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateResult +import org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter + +@OptIn(ExperimentalCoroutinesApi::class) +class ReaderPostLocalSourceTest : BaseUnitTest() { + @Mock + lateinit var readerPostTableWrapper: ReaderPostTableWrapper + + @Mock + lateinit var appPrefsWrapper: AppPrefsWrapper + + private lateinit var localSource: ReaderPostLocalSource + + @Before + fun setUp() { + localSource = ReaderPostLocalSource(readerPostTableWrapper, appPrefsWrapper) + } + + @Test + fun `given no changes and no tag provided, when saveUpdatedPosts, then do nothing`() { + // Given + val serverPosts = ReaderPostList() + val requestedTag = null + whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(UpdateResult.UNCHANGED) + + // it doesn't matter which update action was used, so let's test all of them + ReaderPostServiceStarter.UpdateAction.values().forEach { updateAction -> + clearInvocations(readerPostTableWrapper) + + // When + val result = localSource.saveUpdatedPosts(serverPosts, updateAction, requestedTag) + + // Then + verify(readerPostTableWrapper, only()).comparePosts(serverPosts) // only comparePosts should be + + assertThat(result).isEqualTo(UpdateResult.UNCHANGED) + } + } + + @Test + fun `given no changes and tag provided, when saveUpdatedPosts, then do nothing`() { + // Given + val serverPosts = ReaderPostList() + val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED) + + whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(UpdateResult.UNCHANGED) + + // if the action is any but REQUEST_OLDER_THAN_GAP we should not do anything + ReaderPostServiceStarter.UpdateAction.values() + .filterNot { it == ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER_THAN_GAP } + .forEach { updateAction -> + clearInvocations(readerPostTableWrapper) + + // When + val result = localSource.saveUpdatedPosts(serverPosts, updateAction, requestedTag) + + // Then + verify(readerPostTableWrapper, only()).comparePosts(serverPosts) // only comparePosts should be + + assertThat(result).isEqualTo(UpdateResult.UNCHANGED) + } + } + + @Test + fun `given no changes, tag provided and OLDER_THAN_GAP, when saveUpdatedPosts, then remove gap marker`() { + // Given + val serverPosts = ReaderPostList() + val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED) + + whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(UpdateResult.UNCHANGED) + + // When + val result = localSource.saveUpdatedPosts( + serverPosts, + ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER_THAN_GAP, + requestedTag, + ) + + // Then + verify(readerPostTableWrapper).removeGapMarkerForTag(requestedTag) + + assertThat(result).isEqualTo(UpdateResult.UNCHANGED) + } + + @Test + fun `given new posts and no tag provided, when saveUpdatedPosts, then save posts`() { + // Given + val serverPosts = ReaderPostList() + val requestedTag = null + whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(UpdateResult.HAS_NEW) + + // it doesn't matter which update action was used, so let's test all of them + ReaderPostServiceStarter.UpdateAction.values().forEach { updateAction -> + clearInvocations(readerPostTableWrapper) + + // When + val result = localSource.saveUpdatedPosts(serverPosts, updateAction, requestedTag) + + // Then + verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts) + + assertThat(result).isEqualTo(UpdateResult.HAS_NEW) + } + } + + @Test + fun `given posts changed, tag provided and OLDER_THAN_GAP, when saveUpdatedPosts, then remove gap marker`() { + // Given + val serverPosts = ReaderPostList() + val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED) + + listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult -> + whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult) + clearInvocations(readerPostTableWrapper) + + // When + val result = localSource.saveUpdatedPosts( + serverPosts, + ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER_THAN_GAP, + requestedTag, + ) + + // Then + verify(readerPostTableWrapper).deletePostsBeforeGapMarkerForTag(requestedTag) + verify(readerPostTableWrapper).removeGapMarkerForTag(requestedTag) + verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts) + + assertThat(result).isEqualTo(updateResult) + } + } + + @Test + fun `given posts changed, tag provided and REFRESH, when saveUpdatedPosts, then delete posts and save`() { + // Given + val serverPosts = ReaderPostList() + val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED) + + listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult -> + whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult) + clearInvocations(readerPostTableWrapper) + + // When + val result = localSource.saveUpdatedPosts( + serverPosts, + ReaderPostServiceStarter.UpdateAction.REQUEST_REFRESH, + requestedTag, + ) + + // Then + verify(readerPostTableWrapper).deletePostsWithTag(requestedTag) + verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts) + + assertThat(result).isEqualTo(updateResult) + } + } + + @Test + fun `given posts changed, tag provided and OLDER, when saveUpdatedPosts, then save`() { + // Given + val serverPosts = ReaderPostList() + val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED) + + listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult -> + whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult) + clearInvocations(readerPostTableWrapper) + + + // When + val result = localSource.saveUpdatedPosts( + serverPosts, + ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER, + requestedTag, + ) + + // Then + verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts) + + assertThat(result).isEqualTo(updateResult) + } + } + + @Test + fun `given posts changed, tag provided and NEWER with no gap, when saveUpdatedPosts, then save`() { + // Given + val serverPosts = ReaderPostList().apply { + repeat(4) { add(mock()) } + } + val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED) + + whenever(readerPostTableWrapper.getNumPostsWithTag(requestedTag)).thenReturn(4) + whenever(readerPostTableWrapper.hasOverlap(serverPosts, requestedTag)).thenReturn(true) + + listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult -> + whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult) + clearInvocations(readerPostTableWrapper) + + // When + val result = localSource.saveUpdatedPosts( + serverPosts, + ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER, + requestedTag, + ) + + // Then + verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts) + + assertThat(result).isEqualTo(updateResult) + } + } + + @Test + fun `given posts changed, tag provided and NEWER with gap, when saveUpdatedPosts, then save and set gap`() { + // Given + val serverPosts = ReaderPostList().apply { + repeat(4) { add(mock()) } + } + val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED) + + whenever(readerPostTableWrapper.getNumPostsWithTag(requestedTag)).thenReturn(4) + whenever(readerPostTableWrapper.hasOverlap(serverPosts, requestedTag)).thenReturn(false) + + listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult -> + whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult) + clearInvocations(readerPostTableWrapper) + + // When + val result = localSource.saveUpdatedPosts( + serverPosts, + ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER, + requestedTag, + ) + + // Then + verify(readerPostTableWrapper, never()).deletePostsBeforeGapMarkerForTag(requestedTag) + verify(readerPostTableWrapper, never()).removeGapMarkerForTag(requestedTag) + verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts) + verify(readerPostTableWrapper).setGapMarkerForTag(any(), any(), eq(requestedTag)) + + assertThat(result).isEqualTo(updateResult) + } + } + + @Test + fun `given posts changed, tag provided and NEWER with gap, when saveUpdatedPosts, then keep 1 gap only and save`() { + // Given + val serverPosts = ReaderPostList().apply { + repeat(4) { add(mock()) } + } + val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED) + + whenever(readerPostTableWrapper.getNumPostsWithTag(requestedTag)).thenReturn(5) + whenever(readerPostTableWrapper.hasOverlap(serverPosts, requestedTag)).thenReturn(false) + whenever(readerPostTableWrapper.getGapMarkerIdsForTag(requestedTag)).thenReturn(mock()) + + listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult -> + whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult) + clearInvocations(readerPostTableWrapper) + + // When + val result = localSource.saveUpdatedPosts( + serverPosts, + ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER, + requestedTag, + ) + + // Then + verify(readerPostTableWrapper).deletePostsBeforeGapMarkerForTag(requestedTag) + verify(readerPostTableWrapper).removeGapMarkerForTag(requestedTag) + verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts) + verify(readerPostTableWrapper).setGapMarkerForTag(any(), any(), eq(requestedTag)) + + assertThat(result).isEqualTo(updateResult) + } + } + + @Test + fun `given posts changed, tag provided and update bookmark, when saveUpdatedPosts, then update bookmark`() { + // Given + val serverPosts = ReaderPostList() + val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED) + + listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult -> + whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult) + whenever(appPrefsWrapper.shouldUpdateBookmarkPostsPseudoIds(requestedTag)).thenReturn(true) + + // it doesn't matter which update action was used, so let's test all of them + ReaderPostServiceStarter.UpdateAction.values().forEach { updateAction -> + clearInvocations(readerPostTableWrapper, appPrefsWrapper) + + // When + val result = localSource.saveUpdatedPosts( + serverPosts, + updateAction, + requestedTag, + ) + + // Then + verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts) + verify(readerPostTableWrapper).updateBookmarkedPostPseudoId(serverPosts) + verify(appPrefsWrapper).setBookmarkPostsPseudoIdsUpdated() + + assertThat(result).isEqualTo(updateResult) + } + } + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelProviderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelProviderTest.kt new file mode 100644 index 000000000000..ab0827aaaf68 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelProviderTest.kt @@ -0,0 +1,70 @@ +package org.wordpress.android.ui.reader.subfilter + +import android.os.Bundle +import androidx.fragment.app.Fragment +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.mockito.kotlin.mock +import org.wordpress.android.models.ReaderTag +import org.wordpress.android.ui.reader.ReaderTestUtils + +// fragment implementing SubFilterViewModelProvider for testing purposes only +@Suppress("MemberVisibilityCanBePrivate") +private open class SubFilterViewModelProviderFakeFragment( + val viewModelKeyMap: Map = emptyMap(), + val viewModelTagMap: Map = emptyMap(), +) : Fragment(), SubFilterViewModelProvider { + override fun getSubFilterViewModelForKey(key: String): SubFilterViewModel { + return viewModelKeyMap[key] ?: error("No SubFilterViewModel found for key: $key") + } + + override fun getSubFilterViewModelForTag(tag: ReaderTag, savedInstanceState: Bundle?): SubFilterViewModel { + return viewModelTagMap[tag] ?: error("No SubFilterViewModel found for tag: $tag") + } +} + +class SubFilterViewModelProviderTest { + @Test + fun `getSubFilterViewModelForTag should use given tag for retrieving the appropriate ViewModel`() { + // Given + val tag1 = ReaderTestUtils.createTag("tag1") + val viewModel1: SubFilterViewModel = mock() + + val tag2 = ReaderTestUtils.createTag("tag2") + val viewModel2: SubFilterViewModel = mock() + + val fragment = SubFilterViewModelProviderFakeFragment( + viewModelTagMap = mapOf(tag1 to viewModel1, tag2 to viewModel2) + ) + + // When + val result1 = SubFilterViewModelProvider.getSubFilterViewModelForTag(fragment, tag1) + val result2 = SubFilterViewModelProvider.getSubFilterViewModelForTag(fragment, tag2) + + // Then + assertThat(result1).isEqualTo(viewModel1) + assertThat(result2).isEqualTo(viewModel2) + } + + @Test + fun `getSubFilterViewModelForKey should use given key for retrieving the appropriate ViewModel`() { + // Given + val key1 = "key1" + val viewModel1: SubFilterViewModel = mock() + + val key2 = "key2" + val viewModel2: SubFilterViewModel = mock() + + val fragment = SubFilterViewModelProviderFakeFragment( + viewModelKeyMap = mapOf(key1 to viewModel1, key2 to viewModel2) + ) + + // When + val result1 = SubFilterViewModelProvider.getSubFilterViewModelForKey(fragment, key1) + val result2 = SubFilterViewModelProvider.getSubFilterViewModelForKey(fragment, key2) + + // Then + assertThat(result1).isEqualTo(viewModel1) + assertThat(result2).isEqualTo(viewModel2) + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelTest.kt index dea51ed31922..23e3a40f8b30 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModelTest.kt @@ -17,15 +17,20 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.datasets.ReaderBlogTableWrapper +import org.wordpress.android.datasets.wrappers.ReaderTagTableWrapper import org.wordpress.android.fluxc.model.AccountModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.getOrAwaitValue +import org.wordpress.android.models.ReaderBlog import org.wordpress.android.models.ReaderTag +import org.wordpress.android.models.ReaderTagList import org.wordpress.android.models.ReaderTagType.BOOKMARKED +import org.wordpress.android.models.ReaderTagType.TAGS +import org.wordpress.android.ui.Organization import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.reader.ReaderSubsActivity import org.wordpress.android.ui.reader.ReaderTypes.ReaderPostListType -import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask import org.wordpress.android.ui.reader.subfilter.ActionType.OpenLoginPage import org.wordpress.android.ui.reader.subfilter.ActionType.OpenSubsAtPage @@ -76,7 +81,10 @@ class SubFilterViewModelTest : BaseUnitTest() { private lateinit var savedState: Bundle @Mock - private lateinit var filter: SubfilterListItem + private lateinit var readerTagTableWrapper: ReaderTagTableWrapper + + @Mock + private lateinit var readerBlogTableWrapper: ReaderBlogTableWrapper private lateinit var viewModel: SubFilterViewModel @@ -91,7 +99,9 @@ class SubFilterViewModelTest : BaseUnitTest() { subfilterListItemMapper, eventBusWrapper, accountStore, - readerTracker + readerTracker, + readerTagTableWrapper, + readerBlogTableWrapper, ) viewModel.start(initialTag, savedTag, savedState) @@ -100,6 +110,10 @@ class SubFilterViewModelTest : BaseUnitTest() { @Test fun `current subfilter is set back when we have a previous intance state`() { val json = "{\"blogId\":0,\"feedId\":0,\"tagSlug\":\"news\",\"tagType\":1,\"type\":4}" + val filter = Site( + blog = ReaderBlog(), + onClickAction = mock(), + ) whenever(savedState.getString(SubFilterViewModel.ARG_CURRENT_SUBFILTER_JSON)).thenReturn(json) whenever(subfilterListItemMapper.fromJson(eq(json), any(), any())).thenReturn(filter) @@ -111,7 +125,9 @@ class SubFilterViewModelTest : BaseUnitTest() { subfilterListItemMapper, eventBusWrapper, accountStore, - readerTracker + readerTracker, + readerTagTableWrapper, + readerBlogTableWrapper, ) viewModel.start(initialTag, savedTag, savedState) @@ -286,11 +302,11 @@ class SubFilterViewModelTest : BaseUnitTest() { @Test fun `view model updates the tags and sites and asks to show the bottom sheet when filters button is tapped`() { - var updateTasks: EnumSet? = null - var uiState: BottomSheetUiState? = null + mockReaderTableEmpty() + + var updateTasks: EnumSet? = null viewModel.updateTagsAndSites.observeForever { updateTasks = it.peekContent() } - viewModel.bottomSheetUiState.observeForever { uiState = it.peekContent() } viewModel.onSubFiltersListButtonClicked(SubfilterCategory.SITES) @@ -300,10 +316,48 @@ class SubFilterViewModelTest : BaseUnitTest() { UpdateTask.FOLLOWED_BLOGS ) ) + } + + @Test + fun `view model asks to show the bottom sheet when filters button is tapped`() { + mockReaderTableEmpty() + + var uiState: BottomSheetUiState? = null + + viewModel.bottomSheetUiState.observeForever { uiState = it.peekContent() } + + viewModel.onSubFiltersListButtonClicked(SubfilterCategory.SITES) assertThat(uiState).isInstanceOf(BottomSheetVisible::class.java) } + @Test + fun `view model updates subfilters when filters button is tapped`() { + whenever(initialTag.organization).thenReturn(Organization.NO_ORGANIZATION) + whenever(accountStore.hasAccessToken()).thenReturn(true) + whenever(readerTagTableWrapper.getFollowedTags()).thenReturn( + ReaderTagList().apply { + add(ReaderTag("a", "a", "a", "endpoint-a", TAGS)) + add(ReaderTag("b", "b", "b", "endpoint-b", TAGS)) + add(ReaderTag("c", "c", "c", "endpoint-c", TAGS)) + } + ) + + whenever(readerBlogTableWrapper.getFollowedBlogs()).thenReturn( + List(2) { + ReaderBlog().apply { organizationId = Organization.NO_ORGANIZATION.orgId } + } + ) + + var subFilters: List? = null + + viewModel.subFilters.observeForever { subFilters = it } + + viewModel.onSubFiltersListButtonClicked(SubfilterCategory.SITES) + + assertThat(subFilters).hasSize(5) + } + @Test fun `view model hides the bottom sheet when it is cancelled`() { var uiState: BottomSheetUiState? = null @@ -318,7 +372,11 @@ class SubFilterViewModelTest : BaseUnitTest() { @Test fun `bottom sheet is hidden when a filter is tapped on`() { var uiState: BottomSheetUiState? = null - val filter: SubfilterListItem = mock() + val filter: SubfilterListItem = Site( + isSelected = false, + onClickAction = mock(), + blog = ReaderBlog(), + ) viewModel.setSubfilterFromTag(savedTag) @@ -428,9 +486,8 @@ class SubFilterViewModelTest : BaseUnitTest() { } @Test - fun `Should NOT track READER_FILTER_SHEET_ITEM_SELECTED if clearing filter when onSubfilterSelected is called`() { + fun `Should NOT track READER_FILTER_SHEET_ITEM_SELECTED if SiteAll when onSubfilterSelected is called`() { val filter = SiteAll( - isClearingFilter = true, onClickAction = {}, ) viewModel.onSubfilterSelected(filter) @@ -438,13 +495,45 @@ class SubFilterViewModelTest : BaseUnitTest() { } @Test - fun `Should track READER_FILTER_SHEET_ITEM_SELECTED if NOT clearing filter when onSubfilterSelected is called`() { - val filter = SiteAll( - isClearingFilter = false, + fun `Should NOT track READER_FILTER_SHEET_ITEM_SELECTED if Divider when onSubfilterSelected is called`() { + val filter = SubfilterListItem.Divider + viewModel.onSubfilterSelected(filter) + verify(readerTracker, times(0)).track(AnalyticsTracker.Stat.READER_FILTER_SHEET_ITEM_SELECTED) + } + + @Test + fun `Should NOT track READER_FILTER_SHEET_ITEM_SELECTED if SectionTitle when onSubfilterSelected is called`() { + val filter = SubfilterListItem.SectionTitle(UiStringText("test")) + viewModel.onSubfilterSelected(filter) + verify(readerTracker, times(0)).track(AnalyticsTracker.Stat.READER_FILTER_SHEET_ITEM_SELECTED) + } + + @Test + fun `Should track READER_FILTER_SHEET_ITEM_SELECTED with parameters if type is SITE`() { + val siteFilter = Site( + blog = ReaderBlog(), onClickAction = {}, ) - viewModel.onSubfilterSelected(filter) - verify(readerTracker).track(AnalyticsTracker.Stat.READER_FILTER_SHEET_ITEM_SELECTED) + viewModel.onSubfilterSelected(siteFilter) + verify(readerTracker).track( + stat = AnalyticsTracker.Stat.READER_FILTER_SHEET_ITEM_SELECTED, + properties = mutableMapOf("type" to "site"), + ) + } + + @Test + fun `Should track READER_FILTER_SHEET_ITEM_SELECTED with parameters if type is TAG`() { + val tagFilter = Tag( + tag = ReaderTag( + "", "", "", "", TAGS + ), + onClickAction = {}, + ) + viewModel.onSubfilterSelected(tagFilter) + verify(readerTracker).track( + stat = AnalyticsTracker.Stat.READER_FILTER_SHEET_ITEM_SELECTED, + properties = mutableMapOf("type" to "topic"), + ) } @Test @@ -454,4 +543,11 @@ class SubFilterViewModelTest : BaseUnitTest() { assertThat(viewModel.isTitleContainerVisible.getOrAwaitValue()).isEqualTo(isTitleContainerVisible) } } + + private fun mockReaderTableEmpty() { + whenever(initialTag.organization).thenReturn(Organization.NO_ORGANIZATION) + whenever(accountStore.hasAccessToken()).thenReturn(true) + whenever(readerTagTableWrapper.getFollowedTags()).thenReturn(ReaderTagList()) + whenever(readerBlogTableWrapper.getFollowedBlogs()).thenReturn(emptyList()) + } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/utils/ReaderAnnouncementHelperTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/utils/ReaderAnnouncementHelperTest.kt new file mode 100644 index 000000000000..7a1e99d93069 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/utils/ReaderAnnouncementHelperTest.kt @@ -0,0 +1,150 @@ +package org.wordpress.android.ui.reader.utils + +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.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.R +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.ui.reader.tracker.ReaderTracker +import org.wordpress.android.util.config.ReaderAnnouncementCardFeatureConfig +import org.wordpress.android.util.config.ReaderTagsFeedFeatureConfig + +@RunWith(MockitoJUnitRunner::class) +class ReaderAnnouncementHelperTest { + @Mock + private lateinit var readerAnnouncementCardFeatureConfig: ReaderAnnouncementCardFeatureConfig + + @Mock + private lateinit var readerTagsFeedFeatureConfig: ReaderTagsFeedFeatureConfig + + @Mock + private lateinit var appPrefsWrapper: AppPrefsWrapper + + @Mock + private lateinit var readerTracker: ReaderTracker + + private lateinit var repository: ReaderAnnouncementHelper + + @Before + fun setUp() { + repository = ReaderAnnouncementHelper( + readerAnnouncementCardFeatureConfig, + readerTagsFeedFeatureConfig, + appPrefsWrapper, + readerTracker + ) + } + + @Test + fun `given feature config is off the hasReaderAnnouncement is false`() { + // Given + whenever(readerAnnouncementCardFeatureConfig.isEnabled()).thenReturn(false) + + // When + val hasAnnouncement = repository.hasReaderAnnouncement() + + // Then + assertThat(hasAnnouncement).isFalse() + } + + @Test + fun `given should show announcement in prefs is false the hasReaderAnnouncement is false`() { + // Given + whenever(readerAnnouncementCardFeatureConfig.isEnabled()).thenReturn(true) + whenever(appPrefsWrapper.shouldShowReaderAnnouncementCard()).thenReturn(false) + + // When + val hasAnnouncement = repository.hasReaderAnnouncement() + + // Then + assertThat(hasAnnouncement).isFalse() + } + + @Test + fun `given feature config is on and should show announcement in prefs is true the hasReaderAnnouncement is true`() { + // Given + whenever(readerAnnouncementCardFeatureConfig.isEnabled()).thenReturn(true) + whenever(appPrefsWrapper.shouldShowReaderAnnouncementCard()).thenReturn(true) + + // When + val hasAnnouncement = repository.hasReaderAnnouncement() + + // Then + assertThat(hasAnnouncement).isTrue() + } + + @Test + fun `given tags feed feature is off when getReaderAnnouncementItems then return single item`() { + // Given + whenever(readerAnnouncementCardFeatureConfig.isEnabled()).thenReturn(true) + whenever(appPrefsWrapper.shouldShowReaderAnnouncementCard()).thenReturn(true) + whenever(readerTagsFeedFeatureConfig.isEnabled()).thenReturn(false) + + // When + val items = repository.getReaderAnnouncementItems() + + // Then + assertThat(items).hasSize(1) + + val readerPreferencesItem = items[0] + assertThat(readerPreferencesItem.iconRes).isEqualTo(R.drawable.ic_reader_preferences) + assertThat(readerPreferencesItem.titleRes).isEqualTo( + R.string.reader_announcement_card_reading_preferences_title + ) + assertThat(readerPreferencesItem.descriptionRes).isEqualTo( + R.string.reader_announcement_card_reading_preferences_description + ) + } + + @Test + fun `given tags feed feature is on when getReaderAnnouncementItems then return single item`() { + // Given + whenever(readerAnnouncementCardFeatureConfig.isEnabled()).thenReturn(true) + whenever(appPrefsWrapper.shouldShowReaderAnnouncementCard()).thenReturn(true) + whenever(readerTagsFeedFeatureConfig.isEnabled()).thenReturn(true) + + // When + val items = repository.getReaderAnnouncementItems() + + // Then + assertThat(items).hasSize(2) + + val tagsFeedItem = items[0] + assertThat(tagsFeedItem.iconRes).isEqualTo(R.drawable.ic_reader_tag) + assertThat(tagsFeedItem.titleRes).isEqualTo(R.string.reader_announcement_card_tags_stream_title) + assertThat(tagsFeedItem.descriptionRes).isEqualTo(R.string.reader_announcement_card_tags_stream_description) + + val readerPreferencesItem = items[1] + assertThat(readerPreferencesItem.iconRes).isEqualTo(R.drawable.ic_reader_preferences) + assertThat(readerPreferencesItem.titleRes).isEqualTo( + R.string.reader_announcement_card_reading_preferences_title + ) + assertThat(readerPreferencesItem.descriptionRes).isEqualTo( + R.string.reader_announcement_card_reading_preferences_description + ) + } + + @Test + fun `when dismissReaderAnnouncement then track`() { + // When + repository.dismissReaderAnnouncement() + + // Then + verify(readerTracker).track(AnalyticsTracker.Stat.READER_ANNOUNCEMENT_CARD_DISMISSED) + } + + @Test + fun `when dismissReaderAnnouncement then set should show reader announcement card to false`() { + // When + repository.dismissReaderAnnouncement() + + // Then + verify(appPrefsWrapper).setShouldShowReaderAnnouncementCard(false) + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelperTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelperTest.kt index 0279f25bee2d..03e57616424e 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelperTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelperTest.kt @@ -4,6 +4,7 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.wordpress.android.R import org.wordpress.android.models.ReaderTag import org.wordpress.android.models.ReaderTagList @@ -11,12 +12,18 @@ import org.wordpress.android.models.ReaderTagType import org.wordpress.android.ui.compose.components.menu.dropdown.MenuElementData import org.wordpress.android.ui.utils.UiString.UiStringRes import org.wordpress.android.ui.utils.UiString.UiStringText +import org.wordpress.android.util.config.ReaderTagsFeedFeatureConfig class ReaderTopBarMenuHelperTest { - val helper = ReaderTopBarMenuHelper() + private val readerTagsFeedFeatureConfig: ReaderTagsFeedFeatureConfig = mock() + val helper = ReaderTopBarMenuHelper( + readerTagsFeedFeatureConfig = readerTagsFeedFeatureConfig + ) @Test - fun `GIVEN all tags are available WHEN createMenu THEN all items are created correctly`() { + fun `GIVEN all tags are available and tags FF disabled WHEN createMenu THEN all items are created correctly`() { + whenever(readerTagsFeedFeatureConfig.isEnabled()).thenReturn(false) + val tags = ReaderTagList().apply { add(mockFollowingTag()) // item 0 add(mockDiscoverTag()) // item 1 @@ -65,6 +72,62 @@ class ReaderTopBarMenuHelperTest { assertThat(customList3Item.text).isEqualTo(UiStringText("custom-list-3")) } + @Test + fun `GIVEN all tags are available and tags FF enabled WHEN createMenu THEN all items are created correctly`() { + whenever(readerTagsFeedFeatureConfig.isEnabled()).thenReturn(true) + + val tags = ReaderTagList().apply { + add(mockFollowingTag()) // item 0 + add(mockDiscoverTag()) // item 1 + add(mockSavedTag()) // item 2 + add(mockLikedTag()) // item 3 + add(mockTagsTag()) // item 4 + add(mockA8CTag()) // item 5 + add(mockFollowedP2sTag()) // item 6 + add(createCustomListTag("custom-list-1")) // item 7 + add(createCustomListTag("custom-list-2")) // item 8 + add(createCustomListTag("custom-list-3")) // item 9 + } + + val menu = helper.createMenu(tags) + + // compare the menu items one by one to check their indices + val discoverItem = menu.findSingleItem { it.id == "1" }!! + assertThat(discoverItem.text).isEqualTo(UiStringRes(R.string.reader_dropdown_menu_discover)) + + val subscriptionsItem = menu.findSingleItem { it.id == "0" }!! + assertThat(subscriptionsItem.text).isEqualTo(UiStringRes(R.string.reader_dropdown_menu_subscriptions)) + + val savedItem = menu.findSingleItem { it.id == "2" }!! + assertThat(savedItem.text).isEqualTo(UiStringRes(R.string.reader_dropdown_menu_saved)) + + val likedItem = menu.findSingleItem { it.id == "3" }!! + assertThat(likedItem.text).isEqualTo(UiStringRes(R.string.reader_dropdown_menu_liked)) + + val tagsItem = menu.findSingleItem { it.id == "4" }!! + assertThat(tagsItem.text).isEqualTo(UiStringRes(R.string.reader_dropdown_menu_tags)) + + val a8cItem = menu.findSingleItem { it.id == "5" }!! + assertThat(a8cItem.text).isEqualTo(UiStringRes(R.string.reader_dropdown_menu_automattic)) + + val followedP2sItem = menu.findSingleItem { it.id == "6" }!! + assertThat(followedP2sItem.text).isEqualTo(UiStringText("Followed P2s")) + + assertThat(menu).contains(MenuElementData.Divider) + + val customListsItem = menu.findSubMenu()!! + assertThat(customListsItem.text).isEqualTo(UiStringRes(R.string.reader_dropdown_menu_lists)) + + val customList1Item = customListsItem.children.findSingleItem { it.id == "7" }!! + assertThat(customList1Item.text).isEqualTo(UiStringText("custom-list-1")) + + val customList2Item = customListsItem.children.findSingleItem { it.id == "8" }!! + assertThat(customList2Item.text).isEqualTo(UiStringText("custom-list-2")) + + val customList3Item = customListsItem.children.findSingleItem { it.id == "9" }!! + assertThat(customList3Item.text).isEqualTo(UiStringText("custom-list-3")) + } + @Test fun `GIVEN discover not present WHEN createMenu THEN discover menu item not created`() { val tags = ReaderTagList().apply { @@ -275,6 +338,12 @@ class ReaderTopBarMenuHelperTest { } } + private fun mockTagsTag(): ReaderTag { + return mock { + on { isTags } doReturn true + } + } + private fun createCustomListTag(title: String): ReaderTag { return ReaderTag( title, diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt new file mode 100644 index 000000000000..996295962efa --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt @@ -0,0 +1,1064 @@ +package org.wordpress.android.ui.reader.viewmodels + +import androidx.lifecycle.MediatorLiveData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.datasets.wrappers.ReaderPostTableWrapper +import org.wordpress.android.getOrAwaitValue +import org.wordpress.android.models.ReaderPost +import org.wordpress.android.models.ReaderPostList +import org.wordpress.android.models.ReaderTag +import org.wordpress.android.models.ReaderTagType +import org.wordpress.android.ui.pages.SnackbarMessageHolder +import org.wordpress.android.ui.reader.ReaderTestUtils +import org.wordpress.android.ui.reader.discover.ReaderNavigationEvents +import org.wordpress.android.ui.reader.discover.ReaderPostCardActionsHandler +import org.wordpress.android.ui.reader.discover.ReaderPostMoreButtonUiStateBuilder +import org.wordpress.android.ui.reader.discover.ReaderPostUiStateBuilder +import org.wordpress.android.ui.reader.exceptions.ReaderPostFetchException +import org.wordpress.android.ui.reader.repository.ReaderPostRepository +import org.wordpress.android.ui.reader.repository.usecases.PostLikeUseCase +import org.wordpress.android.ui.reader.tracker.ReaderTracker +import org.wordpress.android.ui.reader.utils.ReaderAnnouncementHelper +import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedUiStateMapper +import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel +import org.wordpress.android.ui.reader.viewmodels.tagsfeed.ReaderTagsFeedViewModel.ActionEvent +import org.wordpress.android.ui.reader.views.compose.ReaderAnnouncementCardItemData +import org.wordpress.android.ui.reader.views.compose.tagsfeed.TagsFeedPostItem +import org.wordpress.android.util.DisplayUtilsWrapper +import org.wordpress.android.util.NetworkUtilsWrapper +import org.wordpress.android.viewmodel.Event +import kotlin.test.assertIs + +@Suppress("LargeClass") +@OptIn(ExperimentalCoroutinesApi::class) +class ReaderTagsFeedViewModelTest : BaseUnitTest() { + @Mock + lateinit var readerPostRepository: ReaderPostRepository + + @Mock + lateinit var readerTagsFeedUiStateMapper: ReaderTagsFeedUiStateMapper + + @Mock + lateinit var readerPostCardActionsHandler: ReaderPostCardActionsHandler + + @Mock + lateinit var readerPostTableWrapper: ReaderPostTableWrapper + + @Mock + lateinit var postLikeUseCase: PostLikeUseCase + + @Mock + lateinit var readerPostMoreButtonUiStateBuilder: ReaderPostMoreButtonUiStateBuilder + + @Mock + lateinit var readerPostUiStateBuilder: ReaderPostUiStateBuilder + + @Mock + lateinit var displayUtilsWrapper: DisplayUtilsWrapper + + @Mock + lateinit var readerTracker: ReaderTracker + + @Mock + lateinit var navigationEvents: MediatorLiveData> + + @Mock + lateinit var snackbarEvents: MediatorLiveData> + + @Mock + lateinit var networkUtilsWrapper: NetworkUtilsWrapper + + @Mock + lateinit var readerAnnouncementHelper: ReaderAnnouncementHelper + + private lateinit var viewModel: ReaderTagsFeedViewModel + + private val collectedUiStates: MutableList = mutableListOf() + + private val actionEvents = mutableListOf() + private val readerNavigationEvents = mutableListOf>() + private val errorMessageEvents = mutableListOf>() + + val tag = ReaderTag( + "tag", + "tag", + "tag", + "endpoint", + ReaderTagType.FOLLOWED, + ) + + @Before + fun setUp() { + viewModel = ReaderTagsFeedViewModel( + bgDispatcher = testDispatcher(), + readerPostRepository = readerPostRepository, + readerTagsFeedUiStateMapper = readerTagsFeedUiStateMapper, + readerPostCardActionsHandler = readerPostCardActionsHandler, + readerPostTableWrapper = readerPostTableWrapper, + postLikeUseCase = postLikeUseCase, + readerPostMoreButtonUiStateBuilder = readerPostMoreButtonUiStateBuilder, + readerPostUiStateBuilder = readerPostUiStateBuilder, + displayUtilsWrapper = displayUtilsWrapper, + readerTracker = readerTracker, + networkUtilsWrapper = networkUtilsWrapper, + readerAnnouncementHelper = readerAnnouncementHelper, + ) + whenever(readerPostCardActionsHandler.navigationEvents) + .thenReturn(navigationEvents) + whenever(readerPostCardActionsHandler.snackbarEvents) + .thenReturn(snackbarEvents) + observeActionEvents() + observeNavigationEvents() + observeErrorMessageEvents() + } + + @Test + fun `when tags changed, then UI state should update properly`() = testCollectingUiStates { + // Given + val tag = ReaderTestUtils.createTag("tag") + mockMapInitialTagFeedItems() + + // When + viewModel.onTagsChanged(listOf(tag)) + advanceUntilIdle() + + // Then + assertThat(collectedUiStates).contains( + ReaderTagsFeedViewModel.UiState.Loaded( + data = listOf(getInitialTagFeedItem(tag)), + ) + ) + } + + @Test + fun `given has announcement, when tags changed, then UI state should update properly`() = testCollectingUiStates { + // Given + val tag = ReaderTestUtils.createTag("tag") + val announcementItems = listOf(mock(), mock()) + mockMapInitialTagFeedItems() + whenever(readerAnnouncementHelper.hasReaderAnnouncement()).thenReturn(true) + whenever(readerAnnouncementHelper.getReaderAnnouncementItems()).thenReturn(announcementItems) + + // When + viewModel.onTagsChanged(listOf(tag)) + advanceUntilIdle() + + // Then + val loadedState = collectedUiStates.last() as ReaderTagsFeedViewModel.UiState.Loaded + assertThat(loadedState.data).isEqualTo(listOf(getInitialTagFeedItem(tag))) + assertThat(loadedState.announcementItem!!.items).isEqualTo(announcementItems) + } + + @Test + fun `given has announcement, when done clicked, then UI state should update properly`() = testCollectingUiStates { + // Given + val tag = ReaderTestUtils.createTag("tag") + val announcementItems = listOf(mock(), mock()) + mockMapInitialTagFeedItems() + whenever(readerAnnouncementHelper.hasReaderAnnouncement()).thenReturn(true) + whenever(readerAnnouncementHelper.getReaderAnnouncementItems()).thenReturn(announcementItems) + + viewModel.onTagsChanged(listOf(tag)) + advanceUntilIdle() + + // When + val loadedState = collectedUiStates.last() as ReaderTagsFeedViewModel.UiState.Loaded + loadedState.announcementItem!!.onDoneClicked() + advanceUntilIdle() + + // Then + verify(readerAnnouncementHelper).dismissReaderAnnouncement() + assertThat(collectedUiStates.last()).isEqualTo( + ReaderTagsFeedViewModel.UiState.Loaded( + data = listOf(getInitialTagFeedItem(tag)), + ) + ) + } + + @Test + fun `given valid tag, when loaded, then UI state should update properly`() = testCollectingUiStates { + // Given + val tag = ReaderTestUtils.createTag("tag") + val posts = ReaderPostList().apply { + add(ReaderPost()) + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag)).doSuspendableAnswer { + delay(100) + posts + } + mockMapInitialTagFeedItems() + mockMapLoadingTagFeedItems() + mockMapLoadedTagFeedItems() + + // When + viewModel.onTagsChanged(listOf(tag)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag)) + advanceUntilIdle() + + // Then + assertThat(collectedUiStates).contains( + ReaderTagsFeedViewModel.UiState.Loaded( + data = listOf(getLoadedTagFeedItem(tag)) + ) + ) + } + + @Test + fun `given invalid tag, when loaded, then UI state should update properly`() = testCollectingUiStates { + // Given + val tag = ReaderTestUtils.createTag("tag") + val error = ReaderPostFetchException("error") + whenever(readerPostRepository.fetchNewerPostsForTag(tag)).doSuspendableAnswer { + delay(100) + throw error + } + mockMapInitialTagFeedItems() + mockMapLoadingTagFeedItems() + mockMapErrorTagFeedItems() + + // When + viewModel.onTagsChanged(listOf(tag)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag)) + advanceUntilIdle() + + // Then + assertThat(collectedUiStates).contains( + ReaderTagsFeedViewModel.UiState.Loaded( + data = listOf(getErrorTagFeedItem(tag)) + ) + ) + } + + @Test + fun `given valid tags, when loaded, then UI state should update properly`() = testCollectingUiStates { + // Given + val tag1 = ReaderTestUtils.createTag("tag1") + val tag2 = ReaderTestUtils.createTag("tag2") + val posts1 = ReaderPostList().apply { + add(ReaderPost()) + } + val posts2 = ReaderPostList().apply { + add(ReaderPost()) + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer { + delay(100) + posts1 + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer { + delay(200) + posts2 + } + mockMapInitialTagFeedItems() + mockMapLoadingTagFeedItems() + mockMapLoadedTagFeedItems() + + // When + viewModel.onTagsChanged(listOf(tag1, tag2)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag1)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag2)) + advanceUntilIdle() + + // Then + assertThat(collectedUiStates).contains( + ReaderTagsFeedViewModel.UiState.Loaded( + data = listOf( + getLoadedTagFeedItem(tag1), + getLoadedTagFeedItem(tag2) + ), + ) + ) + } + + @Test + fun `given valid and invalid tags, when loaded, then UI state should update properly`() = testCollectingUiStates { + // Given + val tag1 = ReaderTestUtils.createTag("tag1") + val tag2 = ReaderTestUtils.createTag("tag2") + val posts1 = ReaderPostList().apply { + add(ReaderPost()) + } + val error2 = ReaderPostFetchException("error") + whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer { + delay(100) + posts1 + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer { + delay(200) + throw error2 + } + mockMapInitialTagFeedItems() + mockMapLoadingTagFeedItems() + mockMapLoadedTagFeedItems() + mockMapErrorTagFeedItems() + + // When + viewModel.onTagsChanged(listOf(tag1, tag2)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag1)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag2)) + advanceUntilIdle() + + // Then + assertThat(collectedUiStates).contains( + ReaderTagsFeedViewModel.UiState.Loaded( + data = listOf( + getLoadedTagFeedItem(tag1), + getErrorTagFeedItem(tag2), + ) + ) + ) + } + + @Test + fun `Should emit FilterTagPostsFeed when onTagChipClick is called`() { + // When + viewModel.onTagChipClick(tag) + + // Then + assertIs(actionEvents.first()) + } + + @Test + fun `Should track READER_TAGS_FEED_HEADER_TAPPED when onTagChipClick is called`() { + // When + viewModel.onTagChipClick(tag) + + // Then + verify(readerTracker).track(AnalyticsTracker.Stat.READER_TAGS_FEED_HEADER_TAPPED) + } + + @Test + fun `Should emit OpenTagPostList when onMoreFromTagClick is called`() { + // When + viewModel.onMoreFromTagClick(tag) + + // Then + assertIs(actionEvents.first()) + } + + @Test + fun `Should track READER_TAGS_FEED_MORE_FROM_TAG_TAPPED when onMoreFromTagClick is called`() { + // When + viewModel.onMoreFromTagClick(tag) + + // Then + verify(readerTracker).track(AnalyticsTracker.Stat.READER_TAGS_FEED_MORE_FROM_TAG_TAPPED) + } + + + @Test + fun `Should emit ShowTagsList when onOpenTagsListClick is called`() { + // When + viewModel.onOpenTagsListClick() + + // Then + assertIs(actionEvents.first()) + } + + @Test + fun `Should emit ShowBlogPreview when onSiteClick is called`() = test { + // Given + whenever(readerPostTableWrapper.getBlogPost(any(), any(), any())) + .thenReturn(ReaderPost()) + + // When + viewModel.onSiteClick(TagsFeedPostItem( + siteName = "", + postDateLine = "", + postTitle = "", + postExcerpt = "", + postImageUrl = "", + postNumberOfLikesText = "", + postNumberOfCommentsText = "", + isPostLiked = true, + isLikeButtonEnabled = true, + postId = 123L, + blogId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {} + )) + + // Then + assertIs>(readerNavigationEvents.first()) + } + + @Test + fun `given tags fetched, when onTagsChanged again, then nothing happens`() = testCollectingUiStates { + // Given + val tag1 = ReaderTestUtils.createTag("tag1") + val tag2 = ReaderTestUtils.createTag("tag2") + val posts1 = ReaderPostList().apply { + add(ReaderPost()) + } + val posts2 = ReaderPostList().apply { + add(ReaderPost()) + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer { + delay(100) + posts1 + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer { + delay(200) + posts2 + } + mockMapInitialTagFeedItems() + mockMapLoadingTagFeedItems() + mockMapLoadedTagFeedItems() + + // When + viewModel.onTagsChanged(listOf(tag1, tag2)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag1)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag2)) + advanceUntilIdle() + val firstCollectedStates = collectedUiStates.toList() + Mockito.clearInvocations(readerPostRepository) + + // Then + viewModel.onTagsChanged(listOf(tag1, tag2)) + advanceUntilIdle() + + assertThat(collectedUiStates).isEqualTo(firstCollectedStates) // still same states, nothing new emitted + verifyNoInteractions(readerPostRepository) + } + + @Test + fun `given new tags fetched, when onTagsChanged again, then state updates`() = testCollectingUiStates { + // Given + val tag1 = ReaderTestUtils.createTag("tag1") // will be present both times + val tag2 = ReaderTestUtils.createTag("tag2") // will be present only first time + val tag3 = ReaderTestUtils.createTag("tag3") // will be present only second time + + val posts1 = ReaderPostList().apply { + add(ReaderPost()) + } + val posts2 = ReaderPostList().apply { + add(ReaderPost()) + } + val posts3 = ReaderPostList().apply { + add(ReaderPost()) + } + + whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer { + delay(100) + posts1 + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer { + delay(200) + posts2 + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag3)).doSuspendableAnswer { + delay(300) + posts3 + } + + mockMapInitialTagFeedItems() + mockMapLoadingTagFeedItems() + mockMapLoadedTagFeedItems() + mockMapInitialTagFeedItem() + + // When + viewModel.onTagsChanged(listOf(tag1, tag2)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag1)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag2)) + advanceUntilIdle() + + assertThat(collectedUiStates.last()).isEqualTo( + ReaderTagsFeedViewModel.UiState.Loaded( + data = listOf( + getLoadedTagFeedItem(tag1), + getLoadedTagFeedItem(tag2), + ) + ) + ) + + // Then + viewModel.onTagsChanged(listOf(tag1, tag3)) + advanceUntilIdle() + + assertThat(collectedUiStates.last()).isEqualTo( + ReaderTagsFeedViewModel.UiState.Loaded( + data = listOf( + getLoadedTagFeedItem(tag1), // still loaded even without entering view + getInitialTagFeedItem(tag3), + ) + ) + ) + } + + @Test + fun `given no tags, when onTagsChanged, then UI state should update properly`() = testCollectingUiStates { + // Given + val tags = emptyList() + + // When + viewModel.onTagsChanged(tags) + advanceUntilIdle() + + // Then + assertThat(collectedUiStates).last().isInstanceOf(ReaderTagsFeedViewModel.UiState.Empty::class.java) + } + + @Test + fun `given tags fetched, when onTagsChanged again refreshing, then move back to initial state`() = + testCollectingUiStates { + // Given + val tag1 = ReaderTestUtils.createTag("tag1") + val tag2 = ReaderTestUtils.createTag("tag2") + val posts1 = ReaderPostList().apply { + add(ReaderPost()) + } + val posts2 = ReaderPostList().apply { + add(ReaderPost()) + } + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer { + delay(100) + posts1 + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer { + delay(200) + posts2 + } + mockMapInitialTagFeedItems() + mockMapLoadingTagFeedItems() + mockMapLoadedTagFeedItems() + + // When + viewModel.onTagsChanged(listOf(tag1, tag2)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag1)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag2)) + advanceUntilIdle() + + viewModel.onRefresh() + + // Then + viewModel.onTagsChanged(listOf(tag1, tag2)) + advanceUntilIdle() + + val loadedState = collectedUiStates.last() as ReaderTagsFeedViewModel.UiState.Loaded + assertThat(loadedState.data).isEqualTo( + listOf( + getInitialTagFeedItem(tag1), + getInitialTagFeedItem(tag2) + ) + ) + assertThat(loadedState.isRefreshing).isFalse() + } + + @Test + fun `given tags fetched, when refreshing, then update isRefreshing status`() = testCollectingUiStates { + // Given + val tag1 = ReaderTestUtils.createTag("tag1") + val tag2 = ReaderTestUtils.createTag("tag2") + val posts1 = ReaderPostList().apply { + add(ReaderPost()) + } + val posts2 = ReaderPostList().apply { + add(ReaderPost()) + } + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer { + delay(100) + posts1 + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer { + delay(200) + posts2 + } + mockMapInitialTagFeedItems() + mockMapLoadingTagFeedItems() + mockMapLoadedTagFeedItems() + + // When + viewModel.onTagsChanged(listOf(tag1, tag2)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag1)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag2)) + advanceUntilIdle() + + // Then + viewModel.onRefresh() + + val loadedState = collectedUiStates.last() as ReaderTagsFeedViewModel.UiState.Loaded + assertThat(loadedState.isRefreshing).isTrue() + } + + @Test + fun `given tags fetched, when refreshing, then RefreshTagsFeed action is posted`() = testCollectingUiStates { + // Given + val tag1 = ReaderTestUtils.createTag("tag1") + val tag2 = ReaderTestUtils.createTag("tag2") + val posts1 = ReaderPostList().apply { + add(ReaderPost()) + } + val posts2 = ReaderPostList().apply { + add(ReaderPost()) + } + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer { + delay(100) + posts1 + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer { + delay(200) + posts2 + } + mockMapInitialTagFeedItems() + mockMapLoadingTagFeedItems() + mockMapLoadedTagFeedItems() + + // When + viewModel.onTagsChanged(listOf(tag1, tag2)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag1)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag2)) + advanceUntilIdle() + + // Then + viewModel.onRefresh() + + val action = viewModel.actionEvents.getOrAwaitValue() + assertThat(action).isEqualTo(ActionEvent.RefreshTags) + } + + @Test + fun `given tags fetched and no connection, when refreshing, then show error message`() = testCollectingUiStates { + // Given + val tag1 = ReaderTestUtils.createTag("tag1") + val tag2 = ReaderTestUtils.createTag("tag2") + val posts1 = ReaderPostList().apply { + add(ReaderPost()) + } + val posts2 = ReaderPostList().apply { + add(ReaderPost()) + } + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer { + delay(100) + posts1 + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer { + delay(200) + posts2 + } + mockMapInitialTagFeedItems() + mockMapLoadingTagFeedItems() + mockMapLoadedTagFeedItems() + + // When + viewModel.onTagsChanged(listOf(tag1, tag2)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag1)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag2)) + advanceUntilIdle() + + // Then + viewModel.onRefresh() + + val messageRes = errorMessageEvents.last().peekContent() + assertThat(messageRes).isEqualTo(R.string.no_network_message) + } + + @Test + fun `Should update UI immediately when like button is tapped`() = testCollectingUiStates { + // Given + val tagsFeedPostItem = TagsFeedPostItem( + siteName = "", + postDateLine = "", + postTitle = "", + postExcerpt = "", + postImageUrl = "", + postNumberOfLikesText = "", + postNumberOfCommentsText = "", + isPostLiked = false, + isLikeButtonEnabled = true, + postId = 123L, + blogId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {} + ) + mockMapInitialTagFeedItems() + mockMapLoadingTagFeedItems() + mockMapLoadedTagFeedItems(items = listOf(tagsFeedPostItem)) + val posts = ReaderPostList().apply { + add(ReaderPost()) + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag)).doSuspendableAnswer { + delay(100) + posts + } + + // When + viewModel.onTagsChanged(listOf(tag)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag)) + advanceUntilIdle() + viewModel.onPostLikeClick(tagsFeedPostItem) + + // Then + val latestUiState = collectedUiStates.last() as ReaderTagsFeedViewModel.UiState.Loaded + val latestUiStatePostList = (latestUiState.data.first().postList as ReaderTagsFeedViewModel.PostList.Loaded) + assertThat(latestUiStatePostList.items.first().isPostLiked).isEqualTo(!tagsFeedPostItem.isPostLiked) + } + + @Test + fun `Should send update like status request when like button is tapped`() = testCollectingUiStates { + // Given + val tagsFeedPostItem = TagsFeedPostItem( + siteName = "", + postDateLine = "", + postTitle = "", + postExcerpt = "", + postImageUrl = "", + postNumberOfLikesText = "", + postNumberOfCommentsText = "", + isPostLiked = false, + isLikeButtonEnabled = true, + postId = 123L, + blogId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {} + ) + mockMapLoadingTagFeedItems() + mockMapLoadedTagFeedItems(items = listOf(tagsFeedPostItem)) + val posts = ReaderPostList().apply { + add(ReaderPost()) + } + whenever(readerPostRepository.fetchNewerPostsForTag(tag)).doSuspendableAnswer { + delay(100) + posts + } + whenever(readerPostTableWrapper.getBlogPost(any(), any(), any())) + .thenReturn(ReaderPost()) + whenever(postLikeUseCase.perform(any(), any(), any())) + .thenReturn(flowOf()) + + // When + viewModel.onTagsChanged(listOf(tag)) + advanceUntilIdle() + viewModel.onPostLikeClick(tagsFeedPostItem) + + // Then + verify(postLikeUseCase).perform(any(), any(), any()) + } + + @Test + fun `Should emit RefreshTags when onBackFromTagDetails is called`() { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + + // When + viewModel.onBackFromTagDetails() + + // Then + assertIs(actionEvents.first()) + } + + @Test + fun `Should not emit RefreshTags when onBackFromTagDetails is called with no connection`() { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + // When + viewModel.onBackFromTagDetails() + + // Then + actionEvents.isEmpty() + } + + @Test + fun `Should track READER_POST_CARD_TAPPED when onPostCardClick is called`() = testCollectingUiStates { + // Given + val blogId = 123L + val feedId = 456L + val isFollowedByCurrentUser = true + whenever(readerPostTableWrapper.getBlogPost(any(), any(), any())) + .thenReturn(ReaderPost().apply { + this.blogId = blogId + this.feedId = feedId + this.isFollowedByCurrentUser = isFollowedByCurrentUser + }) + // When + viewModel.onPostCardClick( + postItem = TagsFeedPostItem( + siteName = "", + postDateLine = "", + postTitle = "", + postExcerpt = "", + postImageUrl = "", + postNumberOfLikesText = "", + postNumberOfCommentsText = "", + isPostLiked = true, + isLikeButtonEnabled = true, + postId = 123L, + blogId = 123L, + onSiteClick = {}, + onPostCardClick = {}, + onPostLikeClick = {}, + onPostMoreMenuClick = {} + ) + ) + + // Then + verify(readerTracker).trackBlog( + stat = AnalyticsTracker.Stat.READER_POST_CARD_TAPPED, + blogId = blogId, + feedId = feedId, + isFollowed = isFollowedByCurrentUser, + source = ReaderTracker.SOURCE_TAGS_FEED, + ) + } + + @Test + fun `should fetch again when onRetryClick is called`() = testCollectingUiStates { + // Given + val tag = ReaderTestUtils.createTag("tag") + val posts = ReaderPostList().apply { + add(ReaderPost()) + } + val error = ReaderPostFetchException("error") + whenever(readerPostRepository.fetchNewerPostsForTag(tag)).doSuspendableAnswer { + delay(100) + throw error + }.doSuspendableAnswer { + delay(100) + posts + } + + mockMapInitialTagFeedItems() + mockMapLoadingTagFeedItems() + mockMapLoadedTagFeedItems() + mockMapErrorTagFeedItems() + + viewModel.onTagsChanged(listOf(tag)) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag)) + advanceUntilIdle() + + assertThat(collectedUiStates.last()).isEqualTo( + ReaderTagsFeedViewModel.UiState.Loaded( + data = listOf(getErrorTagFeedItem(tag)) + ) + ) + + // When + viewModel.onRetryClick(tag) + advanceUntilIdle() + viewModel.onItemEnteredView(getInitialTagFeedItem(tag)) + advanceUntilIdle() + + // Then + assertThat(collectedUiStates).contains( + ReaderTagsFeedViewModel.UiState.Loaded( + data = listOf(getLoadedTagFeedItem(tag)) + ) + ) + } + + @Test + fun `when calling onViewCreated multiple times, then initialize handler once`() = test { + // When + viewModel.onViewCreated() + viewModel.onViewCreated() + + // Then + verify(readerPostCardActionsHandler, times(1)).initScope(any()) + } + + @Test + fun `given connection on, when onViewCreated, then init UI state with Loading`() = testCollectingUiStates { + // Given + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + + // When + viewModel.onViewCreated() + + // Then + assertThat(collectedUiStates.last()).isEqualTo(ReaderTagsFeedViewModel.UiState.Loading) + } + + @Test + fun `given connection off, when onViewCreated, then init UI state with NoConnection`() = testCollectingUiStates { + // Given + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + // When + viewModel.onViewCreated() + + // Then + assertThat(collectedUiStates.last()).isInstanceOf(ReaderTagsFeedViewModel.UiState.NoConnection::class.java) + } + + @Test + fun `given NoConnectionState and connection off, when onRetryClick, then UI state is NoConnection`() = + testCollectingUiStates { + // Given + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + viewModel.onViewCreated() + val noConnectionState = collectedUiStates.last() as ReaderTagsFeedViewModel.UiState.NoConnection + + // When + noConnectionState.onRetryClick() + advanceUntilIdle() + + // Then + val lastStates = collectedUiStates.takeLast(2) + assertThat(lastStates[0]).isEqualTo(ReaderTagsFeedViewModel.UiState.Loading) + assertThat(lastStates[1]).isInstanceOf(ReaderTagsFeedViewModel.UiState.NoConnection::class.java) + } + + @Test + fun `given NoConnectionState and connection on, when onRetryClick, then refresh is requested`() = + testCollectingUiStates { + // Given + whenever(networkUtilsWrapper.isNetworkAvailable()) + .thenReturn(false) + .thenReturn(true) + viewModel.onViewCreated() + val noConnectionState = collectedUiStates.last() as ReaderTagsFeedViewModel.UiState.NoConnection + + // When + noConnectionState.onRetryClick() + advanceUntilIdle() + + // Then + val lastState = collectedUiStates.last() + assertThat(lastState).isEqualTo(ReaderTagsFeedViewModel.UiState.Loading) + viewModel.actionEvents.getOrAwaitValue().let { + assertThat(it).isEqualTo(ActionEvent.RefreshTags) + } + } + + private fun mockMapInitialTagFeedItems() { + whenever( + readerTagsFeedUiStateMapper.mapInitialPostsUiState( + any(), anyOrNull(), any(), any(), any(), any(), any() + ) + ).thenAnswer { + val tags = it.getArgument>(0) + val announcementItem = it.getArgument(1) + ReaderTagsFeedViewModel.UiState.Loaded( + data = tags.map { tag -> getInitialTagFeedItem(tag) }, + announcementItem = announcementItem, + ) + } + } + + private fun mockMapLoadingTagFeedItems() { + whenever(readerTagsFeedUiStateMapper.mapLoadingTagFeedItem(any(), any(), any(), any())) + .thenAnswer { + val tag = it.getArgument(0) + ReaderTagsFeedViewModel.TagFeedItem( + ReaderTagsFeedViewModel.TagChip(tag, {}, {}), + ReaderTagsFeedViewModel.PostList.Loading + ) + } + } + + private fun mockMapLoadedTagFeedItems(items: List = emptyList()) { + whenever( + readerTagsFeedUiStateMapper.mapLoadedTagFeedItem( + any(), any(), any(), any(), any(), any(), any(), any(), any() + ) + ).thenAnswer { + getLoadedTagFeedItem(it.getArgument(0), items) + } + } + + private fun mockMapErrorTagFeedItems() { + whenever(readerTagsFeedUiStateMapper.mapErrorTagFeedItem(any(), any(), any(), any(), any(), any())) + .thenAnswer { + getErrorTagFeedItem(it.getArgument(0)) + } + } + + private fun mockMapInitialTagFeedItem() { + whenever(readerTagsFeedUiStateMapper.mapInitialTagFeedItem(any(), any(), any(), any())) + .thenAnswer { + getInitialTagFeedItem(it.getArgument(0)) + } + } + + private fun getInitialTagFeedItem(tag: ReaderTag) = ReaderTagsFeedViewModel.TagFeedItem( + ReaderTagsFeedViewModel.TagChip(tag, {}, {}), + ReaderTagsFeedViewModel.PostList.Initial + ) + + private fun getLoadedTagFeedItem(tag: ReaderTag, items: List = emptyList()) = + ReaderTagsFeedViewModel.TagFeedItem( + ReaderTagsFeedViewModel.TagChip(tag, {}, {}), + ReaderTagsFeedViewModel.PostList.Loaded(items) + ) + + private fun getErrorTagFeedItem(tag: ReaderTag) = ReaderTagsFeedViewModel.TagFeedItem( + ReaderTagsFeedViewModel.TagChip(tag, {}, {}), + ReaderTagsFeedViewModel.PostList.Error(ReaderTagsFeedViewModel.ErrorType.Default, {}), + ) + + private fun testCollectingUiStates(block: suspend TestScope.() -> Unit) = test { + val collectedUiStatesJob = launch { + collectedUiStates.clear() + viewModel.uiStateFlow.toList(collectedUiStates) + } + this.block() + collectedUiStatesJob.cancel() + } + + private fun observeActionEvents() { + viewModel.actionEvents.observeForever { + it?.let { actionEvents.add(it) } + } + } + + private fun observeNavigationEvents() { + viewModel.navigationEvents.observeForever { + it?.let { readerNavigationEvents.add(it) } + } + } + + private fun observeErrorMessageEvents() { + viewModel.errorMessageEvents.observeForever { + it?.let { errorMessageEvents.add(it) } + } + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderViewModelTest.kt index 2f437f24465c..4fdc83eca39d 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderViewModelTest.kt @@ -31,10 +31,10 @@ import org.wordpress.android.ui.mysite.cards.quickstart.QuickStartRepository import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.quickstart.QuickStartEvent import org.wordpress.android.ui.quickstart.QuickStartType -import org.wordpress.android.ui.reader.utils.ReaderTopBarMenuHelper import org.wordpress.android.ui.reader.tracker.ReaderTracker -import org.wordpress.android.ui.reader.usecases.LoadReaderTabsUseCase +import org.wordpress.android.ui.reader.usecases.LoadReaderItemsUseCase import org.wordpress.android.ui.reader.utils.DateProvider +import org.wordpress.android.ui.reader.utils.ReaderTopBarMenuHelper import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel.QuickStartReaderPrompt import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel.ReaderUiState import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel.ReaderUiState.ContentUiState @@ -42,6 +42,7 @@ import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel.TopBarUiState import org.wordpress.android.util.JetpackBrandingUtils import org.wordpress.android.util.SnackbarSequencer import org.wordpress.android.util.UrlUtilsWrapper +import org.wordpress.android.util.config.ReaderTagsFeedFeatureConfig import org.wordpress.android.viewmodel.Event import java.util.Date @@ -59,7 +60,7 @@ class ReaderViewModelTest : BaseUnitTest() { lateinit var dateProvider: DateProvider @Mock - lateinit var loadReaderTabsUseCase: LoadReaderTabsUseCase + lateinit var loadReaderItemsUseCase: LoadReaderItemsUseCase @Mock lateinit var readerTracker: ReaderTracker @@ -85,8 +86,8 @@ class ReaderViewModelTest : BaseUnitTest() { @Mock lateinit var jetpackFeatureRemovalOverlayUtil: JetpackFeatureRemovalOverlayUtil - private val readerTopBarMenuHelper: ReaderTopBarMenuHelper = ReaderTopBarMenuHelper() - + @Mock + lateinit var readerTagsFeedFeatureConfig: ReaderTagsFeedFeatureConfig private val emptyReaderTagList = ReaderTagList() private val nonEmptyReaderTagList = createNonMockedNonEmptyReaderTagList() @@ -100,7 +101,7 @@ class ReaderViewModelTest : BaseUnitTest() { testDispatcher(), appPrefsWrapper, dateProvider, - loadReaderTabsUseCase, + loadReaderItemsUseCase, readerTracker, accountStore, quickStartRepository, @@ -108,8 +109,9 @@ class ReaderViewModelTest : BaseUnitTest() { jetpackBrandingUtils, snackbarSequencer, jetpackFeatureRemovalOverlayUtil, - readerTopBarMenuHelper, - urlUtilsWrapper + ReaderTopBarMenuHelper(readerTagsFeedFeatureConfig), + urlUtilsWrapper, + readerTagsFeedFeatureConfig, ) whenever(dateProvider.getCurrentDate()).thenReturn(Date(DUMMY_CURRENT_TIME)) @@ -156,7 +158,7 @@ class ReaderViewModelTest : BaseUnitTest() { viewModel.uiState.observeForever { state = it } - whenever(loadReaderTabsUseCase.loadTabs()).thenReturn(ReaderTagList()) + whenever(loadReaderItemsUseCase.load()).thenReturn(ReaderTagList()) // Act triggerContentDisplay() // Assert @@ -550,7 +552,7 @@ class ReaderViewModelTest : BaseUnitTest() { private data class Observers( val uiStates: List, val quickStartReaderPrompts: List>, - val tabNavigationEvents: List + val tabNavigationEvents: List, ) private fun triggerContentDisplay( @@ -562,14 +564,14 @@ class ReaderViewModelTest : BaseUnitTest() { private fun testWithEmptyTags(block: suspend CoroutineScope.() -> T) { test { - whenever(loadReaderTabsUseCase.loadTabs()).thenReturn(emptyReaderTagList) + whenever(loadReaderItemsUseCase.load()).thenReturn(emptyReaderTagList) block() } } private fun testWithNonEmptyTags(block: suspend CoroutineScope.() -> T) { test { - whenever(loadReaderTabsUseCase.loadTabs()).thenReturn(nonEmptyReaderTagList) + whenever(loadReaderItemsUseCase.load()).thenReturn(nonEmptyReaderTagList) block() } } @@ -579,7 +581,7 @@ class ReaderViewModelTest : BaseUnitTest() { block: suspend CoroutineScope.() -> T ) { test { - whenever(loadReaderTabsUseCase.loadTabs()).thenReturn(readerTags) + whenever(loadReaderItemsUseCase.load()).thenReturn(readerTags) block() } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapperTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapperTest.kt new file mode 100644 index 000000000000..797e7ba280a2 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapperTest.kt @@ -0,0 +1,393 @@ +package org.wordpress.android.ui.reader.viewmodels.tagsfeed + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.models.ReaderPost +import org.wordpress.android.models.ReaderPostList +import org.wordpress.android.models.ReaderTag +import org.wordpress.android.models.ReaderTagType +import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper +import org.wordpress.android.ui.reader.views.compose.tagsfeed.TagsFeedPostItem +import org.wordpress.android.util.DateTimeUtilsWrapper +import org.wordpress.android.util.UrlUtilsWrapper +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class ReaderTagsFeedUiStateMapperTest : BaseUnitTest() { + private val dateTimeUtilsWrapper = mock() + + private val readerUtilsWrapper = mock() + + private val urlUtilsWrapper = mock() + + private val classToTest: ReaderTagsFeedUiStateMapper = ReaderTagsFeedUiStateMapper( + dateTimeUtilsWrapper = dateTimeUtilsWrapper, + readerUtilsWrapper = readerUtilsWrapper, + urlUtilsWrapper = urlUtilsWrapper, + ) + + @Suppress("LongMethod") + @Test + fun `Should map loaded TagFeedItem correctly`() { + // Given + val readerPost = ReaderPost().apply { + blogName = "Name" + title = "Title" + excerpt = "Excerpt" + featuredImage = "url" + numLikes = 5 + numReplies = 10 + isLikedByCurrentUser = true + datePublished = "" + } + val postList = ReaderPostList().apply { + add(readerPost) + } + val readerTag = ReaderTag( + "tag", + "tag", + "tag", + "endpoint", + ReaderTagType.FOLLOWED, + ) + val onTagChipClick = { _: ReaderTag -> } + val onMoreFromTagClick = { _: ReaderTag -> } + val onSiteClick: (TagsFeedPostItem) -> Unit = {} + val onPostCardClick: (TagsFeedPostItem) -> Unit = {} + val onPostLikeClick: (TagsFeedPostItem) -> Unit = {} + val onPostMoreMenuClick: (TagsFeedPostItem) -> Unit = {} + val onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit = {} + + val dateLine = "dateLine" + val numberLikesText = "numberLikesText" + val numberCommentsText = "numberCommentsText" + + // When + whenever(dateTimeUtilsWrapper.dateFromIso8601(any())) + .thenReturn(Date(0)) + whenever(dateTimeUtilsWrapper.javaDateToTimeSpan(any())) + .thenReturn(dateLine) + whenever(readerUtilsWrapper.getShortLikeLabelText(readerPost.numLikes)) + .thenReturn(numberLikesText) + whenever(readerUtilsWrapper.getShortCommentLabelText(readerPost.numReplies)) + .thenReturn(numberCommentsText) + + val actual = classToTest.mapLoadedTagFeedItem( + tag = readerTag, + posts = postList, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + onSiteClick = onSiteClick, + onPostCardClick = onPostCardClick, + onPostLikeClick = onPostLikeClick, + onPostMoreMenuClick = onPostMoreMenuClick, + onItemEnteredView = onItemEnteredView, + ) + // Then + val expected = ReaderTagsFeedViewModel.TagFeedItem( + tagChip = ReaderTagsFeedViewModel.TagChip( + tag = readerTag, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + ), + postList = ReaderTagsFeedViewModel.PostList.Loaded( + listOf( + TagsFeedPostItem( + siteName = readerPost.blogName, + postDateLine = dateLine, + postTitle = readerPost.title, + postExcerpt = readerPost.excerpt, + postImageUrl = readerPost.featuredImage, + postNumberOfLikesText = numberLikesText, + postNumberOfCommentsText = numberCommentsText, + isPostLiked = readerPost.isLikedByCurrentUser, + isLikeButtonEnabled = true, + postId = 0L, + blogId = 0L, + onSiteClick = onSiteClick, + onPostLikeClick = onPostLikeClick, + onPostCardClick = onPostCardClick, + onPostMoreMenuClick = onPostMoreMenuClick, + ) + ) + ), + onItemEnteredView = onItemEnteredView, + ) + assertEquals(expected, actual) + } + + @Suppress("LongMethod") + @Test + fun `Should map loaded TagFeedItem correctly with blank blog name`() { + // Given + val readerPost = ReaderPost().apply { + blogName = "" + blogUrl = "https://blogurl.wordpress.com" + title = "Title" + excerpt = "Excerpt" + featuredImage = "url" + numLikes = 5 + numReplies = 10 + isLikedByCurrentUser = true + datePublished = "" + } + val postList = ReaderPostList().apply { + add(readerPost) + } + val readerTag = ReaderTag( + "tag", + "tag", + "tag", + "endpoint", + ReaderTagType.FOLLOWED, + ) + val onTagChipClick = { _: ReaderTag -> } + val onMoreFromTagClick = { _: ReaderTag -> } + val onSiteClick: (TagsFeedPostItem) -> Unit = {} + val onPostCardClick: (TagsFeedPostItem) -> Unit = {} + val onPostLikeClick: (TagsFeedPostItem) -> Unit = {} + val onPostMoreMenuClick: (TagsFeedPostItem) -> Unit = {} + val onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit = {} + + val dateLine = "dateLine" + val numberLikesText = "numberLikesText" + val numberCommentsText = "numberCommentsText" + + // When + whenever(dateTimeUtilsWrapper.dateFromIso8601(any())) + .thenReturn(Date(0)) + whenever(dateTimeUtilsWrapper.javaDateToTimeSpan(any())) + .thenReturn(dateLine) + whenever(readerUtilsWrapper.getShortLikeLabelText(readerPost.numLikes)) + .thenReturn(numberLikesText) + whenever(readerUtilsWrapper.getShortCommentLabelText(readerPost.numReplies)) + .thenReturn(numberCommentsText) + whenever(urlUtilsWrapper.removeScheme(readerPost.blogUrl)) + .thenReturn("blogurl.wordpress.com") + + val actual = classToTest.mapLoadedTagFeedItem( + tag = readerTag, + posts = postList, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + onSiteClick = onSiteClick, + onPostCardClick = onPostCardClick, + onPostLikeClick = onPostLikeClick, + onPostMoreMenuClick = onPostMoreMenuClick, + onItemEnteredView = onItemEnteredView, + ) + // Then + val expected = ReaderTagsFeedViewModel.TagFeedItem( + tagChip = ReaderTagsFeedViewModel.TagChip( + tag = readerTag, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + ), + postList = ReaderTagsFeedViewModel.PostList.Loaded( + listOf( + TagsFeedPostItem( + siteName = "blogurl.wordpress.com", + postDateLine = dateLine, + postTitle = readerPost.title, + postExcerpt = readerPost.excerpt, + postImageUrl = readerPost.featuredImage, + postNumberOfLikesText = numberLikesText, + postNumberOfCommentsText = numberCommentsText, + isPostLiked = readerPost.isLikedByCurrentUser, + isLikeButtonEnabled = true, + postId = 0L, + blogId = 0L, + onSiteClick = onSiteClick, + onPostLikeClick = onPostLikeClick, + onPostCardClick = onPostCardClick, + onPostMoreMenuClick = onPostMoreMenuClick, + ) + ) + ), + onItemEnteredView = onItemEnteredView, + ) + assertEquals(expected, actual) + } + + @Test + fun `Should map error TagFeedItem correctly`() { + // Given + val readerTag = ReaderTag( + "tag", + "tag", + "tag", + "endpoint", + ReaderTagType.FOLLOWED, + ) + val errorType = ReaderTagsFeedViewModel.ErrorType.Default + val onTagChipClick: (ReaderTag) -> Unit = {} + val onMoreFromTagClick: (ReaderTag) -> Unit = {} + val onRetryClick: (ReaderTag) -> Unit = {} + val onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit = {} + // When + val actual = classToTest.mapErrorTagFeedItem( + tag = readerTag, + errorType = errorType, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + onRetryClick = onRetryClick, + onItemEnteredView = onItemEnteredView, + ) + + // Then + val expected = ReaderTagsFeedViewModel.TagFeedItem( + tagChip = ReaderTagsFeedViewModel.TagChip( + tag = readerTag, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + ), + postList = ReaderTagsFeedViewModel.PostList.Error( + type = errorType, + onRetryClick = onRetryClick, + ), + onItemEnteredView = onItemEnteredView, + ) + assertEquals(expected, actual) + } + + @Test + fun `Should map loading TagFeedItem correctly`() { + // Given + val readerTag = ReaderTag( + "tag", + "tag", + "tag", + "endpoint", + ReaderTagType.FOLLOWED, + ) + val onTagChipClick: (ReaderTag) -> Unit = {} + val onMoreFromTagClick: (ReaderTag) -> Unit = {} + val onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit = {} + // When + val actual = classToTest.mapLoadingTagFeedItem( + tag = readerTag, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + onItemEnteredView = onItemEnteredView, + ) + + // Then + val expected = ReaderTagsFeedViewModel.TagFeedItem( + tagChip = ReaderTagsFeedViewModel.TagChip( + tag = readerTag, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + ), + postList = ReaderTagsFeedViewModel.PostList.Loading, + onItemEnteredView = onItemEnteredView, + ) + assertEquals(expected, actual) + } + + @Test + fun `Should map initial TagFeedItem correctly`() { + // Given + val readerTag = ReaderTag( + "tag", + "tag", + "tag", + "endpoint", + ReaderTagType.FOLLOWED, + ) + val onTagChipClick: (ReaderTag) -> Unit = {} + val onMoreFromTagClick: (ReaderTag) -> Unit = {} + val onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit = {} + // When + val actual = classToTest.mapInitialTagFeedItem( + tag = readerTag, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + onItemEnteredView = onItemEnteredView, + ) + + // Then + val expected = ReaderTagsFeedViewModel.TagFeedItem( + tagChip = ReaderTagsFeedViewModel.TagChip( + tag = readerTag, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + ), + postList = ReaderTagsFeedViewModel.PostList.Initial, + onItemEnteredView = onItemEnteredView, + ) + assertEquals(expected, actual) + } + + @Suppress("LongMethod") + @Test + fun `Should map initial posts UI state correctly`() { + // Given + val onTagChipClick: (ReaderTag) -> Unit = {} + val onMoreFromTagClick: (ReaderTag) -> Unit = {} + val onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit = {} + val onRefresh: () -> Unit = {} + val tag1 = ReaderTag( + "tag", + "tag", + "tag", + "endpoint", + ReaderTagType.FOLLOWED, + ) + val tag2 = ReaderTag( + "tag2", + "tag2", + "tag2", + "endpoint2", + ReaderTagType.FOLLOWED, + ) + val tags = listOf(tag1, tag2) + val announcementItem = ReaderTagsFeedViewModel.ReaderAnnouncementItem( + items = listOf(mock(), mock()), + onDoneClicked = {}, + ) + + // When + val actual = classToTest.mapInitialPostsUiState( + tags = tags, + announcementItem = announcementItem, + isRefreshing = true, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + onItemEnteredView = onItemEnteredView, + onRefresh = onRefresh, + ) + + // Then + val expected = ReaderTagsFeedViewModel.UiState.Loaded( + data = listOf( + ReaderTagsFeedViewModel.TagFeedItem( + tagChip = ReaderTagsFeedViewModel.TagChip( + tag = tag1, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + ), + postList = ReaderTagsFeedViewModel.PostList.Initial, + onItemEnteredView = onItemEnteredView, + ), + ReaderTagsFeedViewModel.TagFeedItem( + tagChip = ReaderTagsFeedViewModel.TagChip( + tag = tag2, + onTagChipClick = onTagChipClick, + onMoreFromTagClick = onMoreFromTagClick, + ), + postList = ReaderTagsFeedViewModel.PostList.Initial, + onItemEnteredView = onItemEnteredView, + ) + ), + announcementItem = announcementItem, + isRefreshing = true, + onRefresh = onRefresh, + ) + assertEquals(expected, actual) + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/stats/refresh/lists/sections/insights/usecases/TagsAndCategoriesUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/stats/refresh/lists/sections/insights/usecases/TagsAndCategoriesUseCaseTest.kt index 264262dd7f86..cc3cf536c3cc 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/stats/refresh/lists/sections/insights/usecases/TagsAndCategoriesUseCaseTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/stats/refresh/lists/sections/insights/usecases/TagsAndCategoriesUseCaseTest.kt @@ -246,7 +246,7 @@ class TagsAndCategoriesUseCaseTest : BaseUnitTest() { } else { assertThat(item.barWidth).isNull() } - assertThat(item.icon).isEqualTo(R.drawable.ic_tag_white_24dp) + assertThat(item.icon).isEqualTo(R.drawable.ic_reader_tag) assertThat(item.contentDescription).isEqualTo(contentDescription) } 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 c154d9cdd62d..83135f79892e 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 @@ -88,6 +88,9 @@ public enum Stat { READER_READING_PREFERENCES_FEEDBACK_TAPPED, READER_READING_PREFERENCES_ITEM_TAPPED, READER_READING_PREFERENCES_SAVED, + READER_ANNOUNCEMENT_CARD_DISMISSED, + READER_TAGS_FEED_HEADER_TAPPED, + READER_TAGS_FEED_MORE_FROM_TAG_TAPPED, STATS_ACCESSED, STATS_ACCESS_ERROR, STATS_PERIOD_ACCESSED, @@ -774,6 +777,7 @@ public enum Stat { READER_LIKED_SHOWN, READER_SAVED_LIST_SHOWN, READER_CUSTOM_TAB_SHOWN, + READER_TAGS_FEED_SHOWN, READER_DISCOVER_SHOWN, READER_DISCOVER_PAGINATED, READER_DISCOVER_TOPIC_TAPPED,