From a89c30f785d4636120d3ded12c65da0ec94bc277 Mon Sep 17 00:00:00 2001 From: Matt Chowning Date: Mon, 24 Jul 2023 13:22:24 -0400 Subject: [PATCH] Add share action to swipe menus (#1190) Co-authored-by: Philip Simpson --- CHANGELOG.md | 5 +- .../filters/FilterEpisodeListFragment.kt | 54 ++-- .../filters/FilterEpisodeListViewModel.kt | 68 +--- .../pocketcasts/player/view/UpNextAdapter.kt | 13 +- .../player/view/UpNextEpisodeViewHolder.kt | 18 +- .../pocketcasts/player/view/UpNextFragment.kt | 67 ++-- .../player/view/UpNextTouchCallback.kt | 1 - .../player/viewmodel/PlayerViewModel.kt | 4 - .../src/main/res/layout/adapter_up_next.xml | 36 ++- .../view/ProfileEpisodeListFragment.kt | 56 ++-- .../view/ProfileEpisodeListViewModel.kt | 78 ----- .../podcasts/view/episode/EpisodeFragment.kt | 3 +- .../view/podcast/EpisodeListAdapter.kt | 22 +- .../view/podcast/EpisodeViewHolder.kt | 39 ++- .../podcasts/view/podcast/PodcastAdapter.kt | 11 +- .../podcasts/view/podcast/PodcastFragment.kt | 65 ++-- .../view/podcast/UserEpisodeViewHolder.kt | 29 +- .../podcasts/viewmodel/PodcastViewModel.kt | 66 ---- .../src/main/res/layout/adapter_episode.xml | 38 ++- .../main/res/layout/adapter_user_episode.xml | 36 ++- .../profile/cloud/CloudFilesFragment.kt | 73 +++-- .../profile/cloud/CloudFilesViewModel.kt | 65 ---- .../pocketcasts/analytics/EpisodeAnalytics.kt | 20 +- .../pocketcasts/analytics/SourceView.kt | 4 +- .../repositories/playback/PlaybackManager.kt | 6 +- .../services/ui/src/main/res/values/ids.xml | 4 +- .../pocketcasts/views/dialog/ShareDialog.kt | 22 +- .../views/helper/MultiSwipeHelper.kt | 6 +- .../views/helper/SwipeButtonLayout.kt | 213 +++++++++++++ .../helper/SwipeButtonLayoutViewModel.kt | 259 +++++++++++++++ .../views/helper/SwipeDirection.kt | 6 + .../views/helper/SwipeToArchiveCallback.kt | 269 +++++++++++----- .../multiselect/MultiSelectBottomSheet.kt | 2 +- .../multiselect/MultiSelectEpisodeAction.kt | 14 +- .../multiselect/MultiSelectEpisodesHelper.kt | 46 +++ .../src/main/res/menu/menu_multiselect.xml | 6 +- .../helper/SwipeButtonLayoutViewModelTest.kt | 298 ++++++++++++++++++ 37 files changed, 1440 insertions(+), 582 deletions(-) create mode 100644 modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeButtonLayout.kt create mode 100644 modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeButtonLayoutViewModel.kt create mode 100644 modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeDirection.kt create mode 100644 modules/services/views/src/test/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeButtonLayoutViewModelTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bddd094d15..844ddca6a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,12 @@ ----- * New Feature: * Added 3 episodes on the Filter AutoDownload - ([#1169])(https://github.com/Automattic/pocket-casts-android/pull/1169) + ([#1169](https://github.com/Automattic/pocket-casts-android/pull/1169)) * Added capability to deselect all/below and above on the multiselect feature ([#1172](https://github.com/Automattic/pocket-casts-android/pull/1172)) + * Added share option to episode swipe and multiselect menus + ([#1190](https://github.com/Automattic/pocket-casts-android/pull/1190)), + ([#1191](https://github.com/Automattic/pocket-casts-android/pull/1191)) 7.43 ----- diff --git a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FilterEpisodeListFragment.kt b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FilterEpisodeListFragment.kt index c155fbc052c..afc33d38193 100644 --- a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FilterEpisodeListFragment.kt +++ b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FilterEpisodeListFragment.kt @@ -49,6 +49,8 @@ import au.com.shiftyjelly.pocketcasts.views.fragments.BaseFragment import au.com.shiftyjelly.pocketcasts.views.fragments.BaseFragmentToolbar.ChromeCastButton.Shown import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper import au.com.shiftyjelly.pocketcasts.views.helper.NavigationIcon.BackArrow +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayoutFactory +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayoutViewModel import au.com.shiftyjelly.pocketcasts.views.helper.ToolbarColors import au.com.shiftyjelly.pocketcasts.views.multiselect.MultiSelectEpisodesHelper import au.com.shiftyjelly.pocketcasts.views.multiselect.MultiSelectHelper @@ -80,6 +82,7 @@ class FilterEpisodeListFragment : BaseFragment() { } private val viewModel by viewModels() + private val swipeButtonLayoutViewModel: SwipeButtonLayoutViewModel by viewModels() @Inject lateinit var downloadManager: DownloadManager @Inject lateinit var playbackManager: PlaybackManager @@ -92,7 +95,28 @@ class FilterEpisodeListFragment : BaseFragment() { private lateinit var imageLoader: PodcastImageLoader - private lateinit var adapter: EpisodeListAdapter + private val adapter: EpisodeListAdapter by lazy { + EpisodeListAdapter( + downloadManager = downloadManager, + playbackManager = playbackManager, + upNextQueue = upNextQueue, + settings = settings, + onRowClick = this::onRowClick, + playButtonListener = playButtonListener, + imageLoader = imageLoader, + multiSelectHelper = multiSelectHelper, + fragmentManager = childFragmentManager, + swipeButtonLayoutFactory = SwipeButtonLayoutFactory( + swipeButtonLayoutViewModel = swipeButtonLayoutViewModel, + onItemUpdated = this::lazyNotifyAdapterChanged, + defaultUpNextSwipeAction = { settings.getUpNextSwipeAction() }, + context = requireContext(), + fragmentManager = parentFragmentManager, + swipeSource = EpisodeItemTouchHelper.SwipeSource.FILTERS, + ) + ) + } + private var showingFilterOptionsBeforeMultiSelect: Boolean = false private var multiSelectLoaded: Boolean = false private var listSavedState: Parcelable? = null @@ -114,7 +138,15 @@ class FilterEpisodeListFragment : BaseFragment() { }.smallPlaceholder() playButtonListener.source = SourceView.FILTERS - adapter = EpisodeListAdapter(downloadManager, playbackManager, upNextQueue, settings, this::onRowClick, playButtonListener, imageLoader, multiSelectHelper, childFragmentManager) + } + + // Cannot call notify.notifyItemChanged directly because the compiler gets confused + // when the adapter's constructor includes references to the adapter + private fun lazyNotifyAdapterChanged( + @Suppress("UNUSED_PARAMETER") episode: BaseEpisode, + index: Int, + ) { + adapter.notifyItemChanged(index) } override fun onSaveInstanceState(outState: Bundle) { @@ -383,7 +415,7 @@ class FilterEpisodeListFragment : BaseFragment() { binding.btnChevron.setOnClickListener(clickListener) toolbar.setOnClickListener(clickListener) - val itemTouchHelper = EpisodeItemTouchHelper(this::episodeSwipedRightItem1, this::episodeSwipedRightItem2, viewModel::episodeSwiped) + val itemTouchHelper = EpisodeItemTouchHelper() itemTouchHelper.attachToRecyclerView(recyclerView) val multiSelectToolbar = binding.multiSelectToolbar @@ -563,22 +595,6 @@ class FilterEpisodeListFragment : BaseFragment() { .show(childFragmentManager, "confirm") } - private fun episodeSwipedRightItem1(episode: BaseEpisode, index: Int) { - when (settings.getUpNextSwipeAction()) { - Settings.UpNextAction.PLAY_NEXT -> viewModel.episodeSwipeUpNext(episode) - Settings.UpNextAction.PLAY_LAST -> viewModel.episodeSwipeUpLast(episode) - } - adapter.notifyItemChanged(index) - } - - private fun episodeSwipedRightItem2(episode: BaseEpisode, index: Int) { - when (settings.getUpNextSwipeAction()) { - Settings.UpNextAction.PLAY_NEXT -> viewModel.episodeSwipeUpLast(episode) - Settings.UpNextAction.PLAY_LAST -> viewModel.episodeSwipeUpNext(episode) - } - adapter.notifyItemChanged(index) - } - private fun downloadAll() { val episodeCount = (viewModel.episodesList.value ?: emptyList()).count() if (episodeCount < 5) { diff --git a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FilterEpisodeListViewModel.kt b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FilterEpisodeListViewModel.kt index 80a7fc566a8..01713cd466c 100644 --- a/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FilterEpisodeListViewModel.kt +++ b/modules/features/filters/src/main/java/au/com/shiftyjelly/pocketcasts/filters/FilterEpisodeListViewModel.kt @@ -6,10 +6,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper -import au.com.shiftyjelly.pocketcasts.analytics.EpisodeAnalytics import au.com.shiftyjelly.pocketcasts.analytics.FirebaseAnalyticsTracker import au.com.shiftyjelly.pocketcasts.analytics.SourceView -import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode import au.com.shiftyjelly.pocketcasts.models.entity.Playlist import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode import au.com.shiftyjelly.pocketcasts.preferences.Settings @@ -20,8 +18,6 @@ import au.com.shiftyjelly.pocketcasts.repositories.podcast.PlaylistManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PlaylistProperty import au.com.shiftyjelly.pocketcasts.repositories.podcast.PlaylistUpdateSource import au.com.shiftyjelly.pocketcasts.repositories.podcast.UserPlaylistUpdate -import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper.SwipeAction -import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper.SwipeSource import dagger.hilt.android.lifecycle.HiltViewModel import io.reactivex.BackpressureStrategy import io.reactivex.Flowable @@ -40,18 +36,15 @@ import kotlin.math.min @HiltViewModel class FilterEpisodeListViewModel @Inject constructor( - val playlistManager: PlaylistManager, - val episodeManager: EpisodeManager, - val playbackManager: PlaybackManager, - val downloadManager: DownloadManager, - val settings: Settings, + private val playlistManager: PlaylistManager, + private val episodeManager: EpisodeManager, + private val playbackManager: PlaybackManager, + private val downloadManager: DownloadManager, + private val settings: Settings, private val analyticsTracker: AnalyticsTrackerWrapper, - private val episodeAnalytics: EpisodeAnalytics, ) : ViewModel(), CoroutineScope { companion object { - private const val ACTION_KEY = "action" - private const val SOURCE_KEY = "source" const val MAX_DOWNLOAD_ALL = Settings.MAX_DOWNLOAD } @@ -102,23 +95,6 @@ class FilterEpisodeListViewModel @Inject constructor( } } - @Suppress("UNUSED_PARAMETER") - fun episodeSwiped(episode: BaseEpisode, index: Int) { - if (episode !is PodcastEpisode) return - - launch { - if (!episode.isArchived) { - episodeManager.archive(episode, playbackManager) - trackSwipeAction(SwipeAction.ARCHIVE) - episodeAnalytics.trackEvent(AnalyticsEvent.EPISODE_ARCHIVED, SourceView.FILTERS, episode.uuid) - } else { - episodeManager.unarchive(episode) - trackSwipeAction(SwipeAction.UNARCHIVE) - episodeAnalytics.trackEvent(AnalyticsEvent.EPISODE_UNARCHIVED, SourceView.FILTERS, episode.uuid) - } - } - } - fun onPlayAllFromHere(episode: PodcastEpisode) { launch { val episodes = episodesList.value ?: emptyList() @@ -159,30 +135,6 @@ class FilterEpisodeListViewModel @Inject constructor( } } - fun episodeSwipeUpNext(episode: BaseEpisode) { - launch { - if (playbackManager.upNextQueue.contains(episode.uuid)) { - playbackManager.removeEpisode(episodeToRemove = episode, source = SourceView.FILTERS) - trackSwipeAction(SwipeAction.UP_NEXT_REMOVE) - } else { - playbackManager.playNext(episode = episode, source = SourceView.FILTERS) - trackSwipeAction(SwipeAction.UP_NEXT_ADD_TOP) - } - } - } - - fun episodeSwipeUpLast(episode: BaseEpisode) { - launch { - if (playbackManager.upNextQueue.contains(episode.uuid)) { - playbackManager.removeEpisode(episodeToRemove = episode, source = SourceView.FILTERS) - trackSwipeAction(SwipeAction.UP_NEXT_REMOVE) - } else { - playbackManager.playLast(episode = episode, source = SourceView.FILTERS) - trackSwipeAction(SwipeAction.UP_NEXT_ADD_BOTTOM) - } - } - } - fun downloadAll() { val episodes = (episodesList.value ?: emptyList()) val trimmedList = episodes.subList(0, min(MAX_DOWNLOAD_ALL, episodes.count())) @@ -199,16 +151,6 @@ class FilterEpisodeListViewModel @Inject constructor( return min(episodes.count() - index, settings.getMaxUpNextEpisodes()) } - private fun trackSwipeAction(swipeAction: SwipeAction) { - analyticsTracker.track( - AnalyticsEvent.EPISODE_SWIPE_ACTION_PERFORMED, - mapOf( - ACTION_KEY to swipeAction.analyticsValue, - SOURCE_KEY to SwipeSource.FILTERS.analyticsValue - ) - ) - } - fun onFragmentPause(isChangingConfigurations: Boolean?) { isFragmentChangingConfigurations = isChangingConfigurations ?: false } diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextAdapter.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextAdapter.kt index 09c25022aad..4320eede21a 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextAdapter.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextAdapter.kt @@ -30,6 +30,7 @@ import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.ui.theme.Theme import au.com.shiftyjelly.pocketcasts.ui.theme.ThemeColor import au.com.shiftyjelly.pocketcasts.utils.extensions.dpToPx +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayoutFactory import au.com.shiftyjelly.pocketcasts.views.multiselect.MultiSelectEpisodesHelper import timber.log.Timber import au.com.shiftyjelly.pocketcasts.localization.R as LR @@ -44,7 +45,8 @@ class UpNextAdapter( val fragmentManager: FragmentManager, private val analyticsTracker: AnalyticsTrackerWrapper, private val upNextSource: UpNextSource, - private val settings: Settings + private val settings: Settings, + private val swipeButtonLayoutFactory: SwipeButtonLayoutFactory, ) : ListAdapter(UPNEXT_ADAPTER_DIFF) { private val dateFormatter = RelativeDateFormatter(context) @@ -63,7 +65,14 @@ class UpNextAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) return when (viewType) { - R.layout.adapter_up_next -> UpNextEpisodeViewHolder(DataBindingUtil.inflate(inflater, R.layout.adapter_up_next, parent, false), listener, dateFormatter, imageLoader, episodeManager) + R.layout.adapter_up_next -> UpNextEpisodeViewHolder( + binding = DataBindingUtil.inflate(inflater, R.layout.adapter_up_next, parent, false), + listener = listener, + dateFormatter = dateFormatter, + imageLoader = imageLoader, + episodeManager = episodeManager, + swipeButtonLayoutFactory = swipeButtonLayoutFactory, + ) R.layout.adapter_up_next_footer -> HeaderViewHolder(DataBindingUtil.inflate(inflater, R.layout.adapter_up_next_footer, parent, false)) R.layout.adapter_up_next_playing -> PlayingViewHolder(DataBindingUtil.inflate(inflater, R.layout.adapter_up_next_playing, parent, false)) else -> throw IllegalStateException("Unknown view type in up next") diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextEpisodeViewHolder.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextEpisodeViewHolder.kt index f01eb04740c..f70249e97d6 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextEpisodeViewHolder.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextEpisodeViewHolder.kt @@ -32,6 +32,8 @@ import au.com.shiftyjelly.pocketcasts.utils.extensions.dpToPx import au.com.shiftyjelly.pocketcasts.views.extensions.setRippleBackground import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper import au.com.shiftyjelly.pocketcasts.views.helper.RowSwipeable +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayout +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayoutFactory import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.rxkotlin.subscribeBy @@ -45,13 +47,16 @@ class UpNextEpisodeViewHolder( val listener: UpNextListener?, val dateFormatter: RelativeDateFormatter, val imageLoader: PodcastImageLoader, - val episodeManager: EpisodeManager + val episodeManager: EpisodeManager, + private val swipeButtonLayoutFactory: SwipeButtonLayoutFactory, ) : RecyclerView.ViewHolder(binding.root), UpNextTouchCallback.ItemTouchHelperViewHolder, RowSwipeable { private val elevatedBackground = ContextCompat.getColor(binding.root.context, R.color.elevatedBackground) private val selectedBackground = ContextCompat.getColor(binding.root.context, R.color.selectedBackground) + override lateinit var swipeButtonLayout: SwipeButtonLayout + var disposable: Disposable? = null set(value) { field?.dispose() @@ -95,6 +100,9 @@ class UpNextEpisodeViewHolder( binding.executePendingBindings() } .subscribeBy(onError = { Timber.e(it) }) + + swipeButtonLayout = swipeButtonLayoutFactory.forEpisode(episode) + binding.episode = episode binding.date.text = episode.getSummaryText(dateFormatter = dateFormatter, tintColor = tintColor, showDuration = false, context = binding.date.context) binding.executePendingBindings() @@ -138,14 +146,16 @@ class UpNextEpisodeViewHolder( get() = binding.itemContainer override val episode: BaseEpisode? get() = binding.episode - override val swipeLeftIcon: ImageView - get() = binding.archiveIcon override val positionAdapter: Int get() = bindingAdapterPosition override val leftRightIcon1: ImageView get() = binding.leftRightIcon1 override val leftRightIcon2: ImageView get() = binding.leftRightIcon2 + override val rightLeftIcon1: ImageView + get() = binding.rightLeftIcon1 + override val rightLeftIcon2: ImageView + get() = binding.rightLeftIcon2 override val isMultiSelecting: Boolean get() = binding.checkbox.isVisible override val rightToLeftSwipeLayout: ViewGroup @@ -159,6 +169,6 @@ class UpNextEpisodeViewHolder( EpisodeItemTouchHelper.IconWithBackground(R.drawable.ic_upnext_movetotop, binding.itemContainer.context.getThemeColor(UR.attr.support_04)), EpisodeItemTouchHelper.IconWithBackground(R.drawable.ic_upnext_movetobottom, binding.itemContainer.context.getThemeColor(UR.attr.support_03)) ) - override val rightIconDrawableRes: List + override val rightIconDrawablesRes: List get() = listOf(EpisodeItemTouchHelper.IconWithBackground(R.drawable.ic_upnext_remove, binding.itemContainer.context.getThemeColor(UR.attr.support_05))) } diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextFragment.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextFragment.kt index e795c95a762..741cc9053be 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextFragment.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextFragment.kt @@ -36,8 +36,9 @@ import au.com.shiftyjelly.pocketcasts.ui.theme.ThemeColor import au.com.shiftyjelly.pocketcasts.views.extensions.tintIcons import au.com.shiftyjelly.pocketcasts.views.fragments.BaseFragment import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper -import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper.SwipeAction import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper.SwipeSource +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayoutFactory +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayoutViewModel import au.com.shiftyjelly.pocketcasts.views.multiselect.MultiSelectEpisodesHelper import au.com.shiftyjelly.pocketcasts.views.multiselect.MultiSelectHelper import au.com.shiftyjelly.pocketcasts.views.multiselect.MultiSelectToolbar @@ -58,7 +59,6 @@ class UpNextFragment : BaseFragment(), UpNextListener, UpNextTouchCallback.ItemT companion object { private const val ARG_EMBEDDED = "embedded" private const val ARG_SOURCE = "source" - private const val ACTION_KEY = "action" private const val SOURCE_KEY = "source" private const val SELECT_ALL_KEY = "select_all" private const val DIRECTION_KEY = "direction" @@ -84,6 +84,7 @@ class UpNextFragment : BaseFragment(), UpNextListener, UpNextTouchCallback.ItemT lateinit var adapter: UpNextAdapter private val sourceView = SourceView.UP_NEXT private val playerViewModel: PlayerViewModel by activityViewModels() + private val swipeButtonLayoutViewModel: SwipeButtonLayoutViewModel by activityViewModels() private var userRearrangingFrom: Int? = null private var userDraggingStart: Int? = null private var playingEpisodeAtStartOfDrag: String? = null @@ -138,7 +139,15 @@ class UpNextFragment : BaseFragment(), UpNextListener, UpNextTouchCallback.ItemT fragmentManager = childFragmentManager, analyticsTracker = analyticsTracker, upNextSource = upNextSource, - settings = settings + settings = settings, + swipeButtonLayoutFactory = SwipeButtonLayoutFactory( + swipeButtonLayoutViewModel = swipeButtonLayoutViewModel, + onItemUpdated = this::clearViewAtPosition, + defaultUpNextSwipeAction = { settings.getUpNextSwipeAction() }, + context = context, + fragmentManager = parentFragmentManager, + swipeSource = SwipeSource.UP_NEXT, + ), ) adapter.theme = overrideTheme @@ -148,6 +157,16 @@ class UpNextFragment : BaseFragment(), UpNextListener, UpNextTouchCallback.ItemT } } + private fun clearViewAtPosition( + @Suppress("UNUSED_PARAMETER") episode: BaseEpisode, + position: Int, + ) { + val recyclerView = realBinding?.recyclerView ?: return + recyclerView.findViewHolderForAdapterPosition(position)?.let { + episodeItemTouchHelper?.clearView(recyclerView, it) + } + } + private fun updateStatusAndNavColors() { activity?.let { theme.setNavigationBarColor(it.window, true, ThemeColor.primaryUi03(overrideTheme)) @@ -196,7 +215,7 @@ class UpNextFragment : BaseFragment(), UpNextListener, UpNextTouchCallback.ItemT attachToRecyclerView(recyclerView) } - episodeItemTouchHelper = EpisodeItemTouchHelper(this::moveToTop, this::moveToBottom, this::removeFromUpNext).apply { + episodeItemTouchHelper = EpisodeItemTouchHelper().apply { attachToRecyclerView(recyclerView) } @@ -285,30 +304,6 @@ class UpNextFragment : BaseFragment(), UpNextListener, UpNextTouchCallback.ItemT } } - fun moveToTop(episode: BaseEpisode, position: Int) { - val recyclerView = realBinding?.recyclerView ?: return - recyclerView.findViewHolderForAdapterPosition(position)?.let { - episodeItemTouchHelper?.clearView(recyclerView, it) - } - playbackManager.playEpisodesNext(episodes = listOf(episode), source = SourceView.UP_NEXT) - trackSwipeAction(SwipeAction.UP_NEXT_MOVE_TOP) - } - - fun moveToBottom(episode: BaseEpisode, position: Int) { - val recyclerView = realBinding?.recyclerView ?: return - recyclerView.findViewHolderForAdapterPosition(position)?.let { - episodeItemTouchHelper?.clearView(recyclerView, it) - } - playbackManager.playEpisodesLast(episodes = listOf(episode), source = SourceView.UP_NEXT) - trackSwipeAction(SwipeAction.UP_NEXT_MOVE_BOTTOM) - } - - @Suppress("UNUSED_PARAMETER") - fun removeFromUpNext(episode: BaseEpisode, position: Int) { - onUpNextEpisodeRemove(position) - trackSwipeAction(SwipeAction.UP_NEXT_REMOVE) - } - fun startTour() { val upNextTourView = realBinding?.upNextTourView ?: return if (settings.getSeenUpNextTour()) { @@ -398,12 +393,6 @@ class UpNextFragment : BaseFragment(), UpNextListener, UpNextTouchCallback.ItemT userRearrangingFrom = toPosition } - override fun onUpNextEpisodeRemove(position: Int) { - (upNextItems.getOrNull(position) as? BaseEpisode)?.let { - playerViewModel.removeFromUpNext(it) - } - } - override fun onUpNextItemTouchHelperFinished(position: Int) { if (playingEpisodeAtStartOfDrag == playbackManager.upNextQueue.currentEpisode?.uuid) { playerViewModel.changeUpNextEpisodes(upNextItems.subList(1, upNextItems.size).filterIsInstance()) @@ -437,16 +426,6 @@ class UpNextFragment : BaseFragment(), UpNextListener, UpNextTouchCallback.ItemT close() } - private fun trackSwipeAction(swipeAction: SwipeAction) { - analyticsTracker.track( - AnalyticsEvent.EPISODE_SWIPE_ACTION_PERFORMED, - mapOf( - ACTION_KEY to swipeAction.analyticsValue, - SOURCE_KEY to SwipeSource.UP_NEXT.analyticsValue - ) - ) - } - private fun trackUpNextEvent(event: AnalyticsEvent, props: Map = emptyMap()) { val properties = HashMap() properties[SOURCE_KEY] = upNextSource.analyticsValue diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextTouchCallback.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextTouchCallback.kt index 0ef7bf1a620..bcfbfa89e52 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextTouchCallback.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/UpNextTouchCallback.kt @@ -13,7 +13,6 @@ class UpNextTouchCallback( interface ItemTouchHelperAdapter { fun onUpNextEpisodeMove(fromPosition: Int, toPosition: Int) - fun onUpNextEpisodeRemove(position: Int) fun onUpNextEpisodeStartDrag(viewHolder: RecyclerView.ViewHolder) fun onUpNextItemTouchHelperFinished(position: Int) } diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModel.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModel.kt index 64188a8f70d..0a9c0bf593f 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModel.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModel.kt @@ -519,10 +519,6 @@ class PlayerViewModel @Inject constructor( disposables.clear() } - fun removeFromUpNext(episode: BaseEpisode) { - playbackManager.removeEpisode(episodeToRemove = episode, source = source) - } - private fun calcCustomTimeText(): String { return context.resources.getString(LR.string.minutes_plural, sleepCustomTimeMins) } diff --git a/modules/features/player/src/main/res/layout/adapter_up_next.xml b/modules/features/player/src/main/res/layout/adapter_up_next.xml index 305c83ac04b..de2ab59b604 100644 --- a/modules/features/player/src/main/res/layout/adapter_up_next.xml +++ b/modules/features/player/src/main/res/layout/adapter_up_next.xml @@ -18,14 +18,34 @@ android:background="?attr/support_06" android:importantForAccessibility="noHideDescendants"> - + + + + + + + EpisodeItemTouchHelper.SwipeSource.DOWNLOADS + Mode.History -> EpisodeItemTouchHelper.SwipeSource.LISTENING_HISTORY + Mode.Starred -> EpisodeItemTouchHelper.SwipeSource.STARRED + }, + ) + ) + } + + // Cannot inline this because the compiler gets confused + // when the adapter's constructor includes references to the adapter + private fun lazyNotifyItemChanged( + @Suppress("UNUSED_PARAMETER") episode: BaseEpisode, + index: Int, + ) { + adapter.notifyItemChanged(index) + } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { binding = FragmentProfileEpisodeListBinding.inflate(inflater, container, false) @@ -164,7 +200,7 @@ class ProfileEpisodeListFragment : BaseFragment(), Toolbar.OnMenuItemClickListen it.layoutManager = LinearLayoutManager(it.context, RecyclerView.VERTICAL, false) it.adapter = adapter (it.itemAnimator as SimpleItemAnimator).changeDuration = 0 - val itemTouchHelper = EpisodeItemTouchHelper(this::episodeSwipedRightItem1, this::episodeSwipedRightItem2, viewModel::episodeSwiped) + val itemTouchHelper = EpisodeItemTouchHelper() itemTouchHelper.attachToRecyclerView(it) } @@ -345,22 +381,6 @@ class ProfileEpisodeListFragment : BaseFragment(), Toolbar.OnMenuItemClickListen (activity as? FragmentHostListener)?.addFragment(fragment) } - private fun episodeSwipedRightItem1(episode: BaseEpisode, index: Int) { - when (settings.getUpNextSwipeAction()) { - Settings.UpNextAction.PLAY_NEXT -> viewModel.episodeSwipeUpNext(episode) - Settings.UpNextAction.PLAY_LAST -> viewModel.episodeSwipeUpLast(episode) - } - adapter.notifyItemChanged(index) - } - - private fun episodeSwipedRightItem2(episode: BaseEpisode, index: Int) { - when (settings.getUpNextSwipeAction()) { - Settings.UpNextAction.PLAY_NEXT -> viewModel.episodeSwipeUpLast(episode) - Settings.UpNextAction.PLAY_LAST -> viewModel.episodeSwipeUpNext(episode) - } - adapter.notifyItemChanged(index) - } - override fun onBackPressed(): Boolean { return if (multiSelectHelper.isMultiSelecting) { multiSelectHelper.isMultiSelecting = false diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/ProfileEpisodeListViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/ProfileEpisodeListViewModel.kt index e17b602b5a6..6e94fcda405 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/ProfileEpisodeListViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/ProfileEpisodeListViewModel.kt @@ -5,36 +5,28 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper -import au.com.shiftyjelly.pocketcasts.analytics.EpisodeAnalytics -import au.com.shiftyjelly.pocketcasts.analytics.SourceView -import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager -import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper.SwipeAction import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.coroutines.CoroutineContext -import kotlin.math.max @HiltViewModel class ProfileEpisodeListViewModel @Inject constructor( val episodeManager: EpisodeManager, val playbackManager: PlaybackManager, private val analyticsTracker: AnalyticsTrackerWrapper, - private val episodeAnalytics: EpisodeAnalytics, ) : ViewModel(), CoroutineScope { override val coroutineContext: CoroutineContext get() = Dispatchers.Default lateinit var episodeList: LiveData> - private lateinit var mode: ProfileEpisodeListFragment.Mode fun setup(mode: ProfileEpisodeListFragment.Mode) { - this.mode = mode val episodeListFlowable = when (mode) { is ProfileEpisodeListFragment.Mode.Downloaded -> episodeManager.observeDownloadEpisodes() is ProfileEpisodeListFragment.Mode.Starred -> episodeManager.observeStarredEpisodes() @@ -44,80 +36,10 @@ class ProfileEpisodeListViewModel @Inject constructor( episodeList = episodeListFlowable.toLiveData() } - @Suppress("UNUSED_PARAMETER") - fun episodeSwiped(episode: BaseEpisode, index: Int) { - if (episode !is PodcastEpisode) return - - launch { - val source = getAnalyticsSource() - if (!episode.isArchived) { - episodeManager.archive(episode, playbackManager) - trackSwipeAction(SwipeAction.ARCHIVE) - episodeAnalytics.trackEvent(AnalyticsEvent.EPISODE_ARCHIVED, source, episode.uuid) - } else { - episodeManager.unarchive(episode) - trackSwipeAction(SwipeAction.UNARCHIVE) - episodeAnalytics.trackEvent(AnalyticsEvent.EPISODE_UNARCHIVED, source, episode.uuid) - } - } - } - - fun episodeSwipeUpNext(episode: BaseEpisode) { - launch { - if (playbackManager.upNextQueue.contains(episode.uuid)) { - playbackManager.removeEpisode(episodeToRemove = episode, source = getAnalyticsSource()) - trackSwipeAction(SwipeAction.UP_NEXT_REMOVE) - } else { - playbackManager.playNext(episode = episode, source = getAnalyticsSource()) - trackSwipeAction(SwipeAction.UP_NEXT_ADD_TOP) - } - } - } - - fun episodeSwipeUpLast(episode: BaseEpisode) { - launch { - if (playbackManager.upNextQueue.contains(episode.uuid)) { - playbackManager.removeEpisode(episodeToRemove = episode, source = getAnalyticsSource()) - trackSwipeAction(SwipeAction.UP_NEXT_REMOVE) - } else { - playbackManager.playLast(episode = episode, source = getAnalyticsSource()) - trackSwipeAction(SwipeAction.UP_NEXT_ADD_BOTTOM) - } - } - } - - fun onArchiveFromHereCount(episode: PodcastEpisode): Int { - val episodes = episodeList.value ?: return 0 - val index = max(episodes.indexOf(episode), 0) // -1 on not found - return episodes.count() - index - } - fun clearAllEpisodeHistory() { launch { analyticsTracker.track(AnalyticsEvent.LISTENING_HISTORY_CLEARED) episodeManager.clearAllEpisodeHistory() } } - - private fun trackSwipeAction(swipeAction: SwipeAction) { - val source = getAnalyticsSource() - analyticsTracker.track( - AnalyticsEvent.EPISODE_SWIPE_ACTION_PERFORMED, - mapOf( - ACTION_KEY to swipeAction.analyticsValue, - SOURCE_KEY to source.analyticsValue - ) - ) - } - - private fun getAnalyticsSource() = when (mode) { - ProfileEpisodeListFragment.Mode.Downloaded -> SourceView.DOWNLOADS - ProfileEpisodeListFragment.Mode.History -> SourceView.LISTENING_HISTORY - ProfileEpisodeListFragment.Mode.Starred -> SourceView.STARRED - } - - companion object { - private const val ACTION_KEY = "action" - private const val SOURCE_KEY = "source" - } } diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt index 37e1948535d..6405eec554a 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/episode/EpisodeFragment.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.Observer import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper import au.com.shiftyjelly.pocketcasts.analytics.FirebaseAnalyticsTracker +import au.com.shiftyjelly.pocketcasts.analytics.SourceView import au.com.shiftyjelly.pocketcasts.localization.helper.TimeHelper import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode import au.com.shiftyjelly.pocketcasts.models.type.EpisodePlayingStatus @@ -599,6 +600,6 @@ class EpisodeFragment : BaseDialogFragment() { context, shouldShowPodcast = false, analyticsTracker = analyticsTracker, - ).show() + ).show(sourceView = SourceView.EPISODE_DETAILS) } } diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/EpisodeListAdapter.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/EpisodeListAdapter.kt index 70cfc7288de..f19484823ee 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/EpisodeListAdapter.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/EpisodeListAdapter.kt @@ -21,6 +21,7 @@ import au.com.shiftyjelly.pocketcasts.repositories.images.PodcastImageLoader import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.playback.UpNextQueue import au.com.shiftyjelly.pocketcasts.ui.extensions.getThemeColor +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayoutFactory import au.com.shiftyjelly.pocketcasts.views.multiselect.MultiSelectEpisodesHelper import io.reactivex.disposables.CompositeDisposable import au.com.shiftyjelly.pocketcasts.ui.R as UR @@ -47,6 +48,7 @@ class EpisodeListAdapter( val multiSelectHelper: MultiSelectEpisodesHelper, val fragmentManager: FragmentManager, val fromListUuid: String? = null, + val swipeButtonLayoutFactory: SwipeButtonLayoutFactory, ) : ListAdapter(PLAYBACK_DIFF) { val disposables = CompositeDisposable() @@ -64,8 +66,24 @@ class EpisodeListAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) return when (viewType) { - R.layout.adapter_episode -> EpisodeViewHolder(AdapterEpisodeBinding.inflate(inflater, parent, false), EpisodeViewHolder.ViewMode.Artwork, downloadManager.progressUpdateRelay, playbackManager.playbackStateRelay, upNextQueue.changesObservable, imageLoader) - R.layout.adapter_user_episode -> UserEpisodeViewHolder(AdapterUserEpisodeBinding.inflate(inflater, parent, false), UserEpisodeViewHolder.ViewMode.Artwork, downloadManager.progressUpdateRelay, playbackManager.playbackStateRelay, upNextQueue.changesObservable, imageLoader) + R.layout.adapter_episode -> EpisodeViewHolder( + binding = AdapterEpisodeBinding.inflate(inflater, parent, false), + viewMode = EpisodeViewHolder.ViewMode.Artwork, + downloadProgressUpdates = downloadManager.progressUpdateRelay, + playbackStateUpdates = playbackManager.playbackStateRelay, + upNextChangesObservable = upNextQueue.changesObservable, + imageLoader = imageLoader, + swipeButtonLayoutFactory = swipeButtonLayoutFactory + ) + R.layout.adapter_user_episode -> UserEpisodeViewHolder( + binding = AdapterUserEpisodeBinding.inflate(inflater, parent, false), + viewMode = UserEpisodeViewHolder.ViewMode.Artwork, + downloadProgressUpdates = downloadManager.progressUpdateRelay, + playbackStateUpdates = playbackManager.playbackStateRelay, + upNextChangesObservable = upNextQueue.changesObservable, + imageLoader = imageLoader, + swipeButtonLayoutFactory = swipeButtonLayoutFactory + ) else -> throw IllegalStateException("Unknown playable type") } } diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/EpisodeViewHolder.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/EpisodeViewHolder.kt index 4cf31ddb653..3b7850f251a 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/EpisodeViewHolder.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/EpisodeViewHolder.kt @@ -35,6 +35,8 @@ import au.com.shiftyjelly.pocketcasts.ui.helper.ColorUtils import au.com.shiftyjelly.pocketcasts.utils.extensions.dpToPx import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper import au.com.shiftyjelly.pocketcasts.views.helper.RowSwipeable +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayout +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayoutFactory import io.reactivex.BackpressureStrategy import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers @@ -49,22 +51,25 @@ import au.com.shiftyjelly.pocketcasts.images.R as IR import au.com.shiftyjelly.pocketcasts.localization.R as LR import au.com.shiftyjelly.pocketcasts.ui.R as UR -class EpisodeViewHolder( +class EpisodeViewHolder constructor( val binding: AdapterEpisodeBinding, val viewMode: ViewMode, val downloadProgressUpdates: Observable, val playbackStateUpdates: Observable, val upNextChangesObservable: Observable, - val imageLoader: PodcastImageLoader? = null + val imageLoader: PodcastImageLoader? = null, + private val swipeButtonLayoutFactory: SwipeButtonLayoutFactory, ) : RecyclerView.ViewHolder(binding.root), RowSwipeable { override val episodeRow: ViewGroup get() = binding.episodeRow - override val swipeLeftIcon: ImageView - get() = binding.archiveIcon override val leftRightIcon1: ImageView get() = binding.leftRightIcon1 override val leftRightIcon2: ImageView get() = binding.leftRightIcon2 + override val rightLeftIcon1: ImageView + get() = binding.rightLeftIcon1 + override val rightLeftIcon2: ImageView + get() = binding.rightLeftIcon2 override val episode: BaseEpisode? get() = binding.episode override val positionAdapter: Int @@ -79,6 +84,8 @@ class EpisodeViewHolder( object Artwork : ViewMode() } + override lateinit var swipeButtonLayout: SwipeButtonLayout + val dateFormatter = RelativeDateFormatter(context) val context: Context get() = binding.root.context @@ -105,12 +112,24 @@ class EpisodeViewHolder( ) } } - override val rightIconDrawableRes: List + override val rightIconDrawablesRes: List get() { - return if (episode?.isArchived == true) - listOf(EpisodeItemTouchHelper.IconWithBackground(IR.drawable.ic_unarchive, binding.episodeRow.context.getThemeColor(UR.attr.support_06))) - else - listOf(EpisodeItemTouchHelper.IconWithBackground(IR.drawable.ic_archive, binding.episodeRow.context.getThemeColor(UR.attr.support_06))) + + val archiveItem = EpisodeItemTouchHelper.IconWithBackground( + iconRes = if (episode?.isArchived == true) { + IR.drawable.ic_unarchive + } else { + IR.drawable.ic_archive + }, + backgroundColor = binding.episodeRow.context.getThemeColor(UR.attr.support_06) + ) + + val shareItem = EpisodeItemTouchHelper.IconWithBackground( + iconRes = IR.drawable.ic_share, + backgroundColor = binding.episodeRow.context.getThemeColor(UR.attr.support_01) + ) + + return listOf(archiveItem, shareItem) } fun setup(episode: PodcastEpisode, fromListUuid: String?, tintColor: Int, playButtonListener: PlayButton.OnClickListener, streamByDefault: Boolean, upNextAction: Settings.UpNextAction, multiSelectEnabled: Boolean = false, isSelected: Boolean = false, disposables: CompositeDisposable) { @@ -126,6 +145,8 @@ class EpisodeViewHolder( updateTimeLeft(textView = binding.lblStatus, episode = episode) } binding.episode = episode + swipeButtonLayout = swipeButtonLayoutFactory.forEpisode(episode) + binding.playButton.listener = playButtonListener val captionColor = context.getThemeColor(UR.attr.primary_text_02) diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAdapter.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAdapter.kt index 08016ad7481..4f9c98243fc 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAdapter.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastAdapter.kt @@ -53,6 +53,7 @@ import au.com.shiftyjelly.pocketcasts.views.extensions.hide import au.com.shiftyjelly.pocketcasts.views.extensions.show import au.com.shiftyjelly.pocketcasts.views.extensions.toggleVisibility import au.com.shiftyjelly.pocketcasts.views.helper.AnimatorUtil +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayoutFactory import au.com.shiftyjelly.pocketcasts.views.multiselect.MultiSelectEpisodesHelper import io.reactivex.disposables.CompositeDisposable import timber.log.Timber @@ -111,6 +112,7 @@ class PodcastAdapter( private val multiSelectHelper: MultiSelectEpisodesHelper, private val onArtworkLongClicked: (successCallback: () -> Unit) -> Unit, private val ratingsViewModel: PodcastRatingsViewModel, + private val swipeButtonLayoutFactory: SwipeButtonLayoutFactory, ) : LargeListAdapter(1500, differ) { data class EpisodeLimitRow(val episodeLimit: Int) @@ -150,7 +152,14 @@ class PodcastAdapter( VIEW_TYPE_EPISODE_LIMIT_ROW -> EpisodeLimitViewHolder(inflater.inflate(R.layout.adapter_episode_limit, parent, false)) VIEW_TYPE_NO_EPISODE -> NoEpisodesViewHolder(inflater.inflate(R.layout.adapter_no_episodes, parent, false)) VIEW_TYPE_DIVIDER -> DividerViewHolder(inflater.inflate(R.layout.adapter_divider_row, parent, false)) - else -> EpisodeViewHolder(AdapterEpisodeBinding.inflate(inflater, parent, false), EpisodeViewHolder.ViewMode.NoArtwork, downloadManager.progressUpdateRelay, playbackManager.playbackStateRelay, upNextQueue.changesObservable) + else -> EpisodeViewHolder( + binding = AdapterEpisodeBinding.inflate(inflater, parent, false), + viewMode = EpisodeViewHolder.ViewMode.NoArtwork, + downloadProgressUpdates = downloadManager.progressUpdateRelay, + playbackStateUpdates = playbackManager.playbackStateRelay, + upNextChangesObservable = upNextQueue.changesObservable, + swipeButtonLayoutFactory = swipeButtonLayoutFactory, + ) } } diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastFragment.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastFragment.kt index 3060f55470b..7ed2897a40b 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastFragment.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/PodcastFragment.kt @@ -62,6 +62,8 @@ import au.com.shiftyjelly.pocketcasts.views.extensions.smoothScrollToTop import au.com.shiftyjelly.pocketcasts.views.extensions.tintIcons import au.com.shiftyjelly.pocketcasts.views.fragments.BaseFragment import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayoutFactory +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayoutViewModel import au.com.shiftyjelly.pocketcasts.views.helper.UiUtil import au.com.shiftyjelly.pocketcasts.views.multiselect.MultiSelectEpisodesHelper import au.com.shiftyjelly.pocketcasts.views.multiselect.MultiSelectHelper @@ -119,6 +121,7 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener, Corouti private val viewModel: PodcastViewModel by viewModels() private val ratingsViewModel: PodcastRatingsViewModel by viewModels() + private val swipeButtonLayoutViewModel: SwipeButtonLayoutViewModel by viewModels() private var adapter: PodcastAdapter? = null private var binding: FragmentPodcastBinding? = null @@ -489,16 +492,6 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener, Corouti binding?.episodesRecyclerView?.layoutManager?.onRestoreInstanceState(listState) } - fun episodeSwipeArchive(episode: BaseEpisode, index: Int) { - val binding = binding ?: return - - binding.episodesRecyclerView.findViewHolderForAdapterPosition(index)?.let { - itemTouchHelper.clearView(binding.episodesRecyclerView, it) - } - - viewModel.episodeSwipeArchive(episode, index) - } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val binding = FragmentPodcastBinding.inflate(inflater, container, false) this.binding = binding @@ -509,7 +502,7 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener, Corouti statusBarColor = StatusBarColor.Custom(headerColor, true) updateStatusBar() - itemTouchHelper = EpisodeItemTouchHelper(this::episodeSwipedRightItem1, this::episodeSwipedRightItem2, this::episodeSwipeArchive) + itemTouchHelper = EpisodeItemTouchHelper() loadData() @@ -558,6 +551,14 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener, Corouti multiSelectHelper = multiSelectHelper, onArtworkLongClicked = onArtworkLongClicked, ratingsViewModel = ratingsViewModel, + swipeButtonLayoutFactory = SwipeButtonLayoutFactory( + swipeButtonLayoutViewModel = swipeButtonLayoutViewModel, + onItemUpdated = ::notifyItemChanged, + defaultUpNextSwipeAction = { settings.getUpNextSwipeAction() }, + context = context, + fragmentManager = parentFragmentManager, + swipeSource = EpisodeItemTouchHelper.SwipeSource.PODCAST_DETAILS, + ) ) } @@ -660,6 +661,19 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener, Corouti return binding.root } + private fun notifyItemChanged( + @Suppress("UNUSED_PARAMETER") episode: BaseEpisode, + index: Int, + ) { + binding?.episodesRecyclerView?.let { recyclerView -> + recyclerView.findViewHolderForAdapterPosition(index)?.let { + itemTouchHelper.clearView(recyclerView, it) + } + } + + adapter?.notifyItemChanged(index) + } + private fun loadData() { LogBuffer.i(LogBuffer.TAG_BACKGROUND_TASKS, "Loading podcast page for $podcastUuid") viewModel.loadPodcast(podcastUuid, resources) @@ -765,35 +779,6 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener, Corouti binding = null } - private fun episodeSwipedRightItem1(episode: BaseEpisode, index: Int) { - when (settings.getUpNextSwipeAction()) { - Settings.UpNextAction.PLAY_NEXT -> viewModel.episodeSwipeUpNext(episode) - Settings.UpNextAction.PLAY_LAST -> viewModel.episodeSwipeUpLast(episode) - } - - binding?.episodesRecyclerView?.let { recyclerView -> - recyclerView.findViewHolderForAdapterPosition(index)?.let { - itemTouchHelper.clearView(recyclerView, it) - } - } - - adapter?.notifyItemChanged(index) - } - - private fun episodeSwipedRightItem2(episode: BaseEpisode, index: Int) { - when (settings.getUpNextSwipeAction()) { - Settings.UpNextAction.PLAY_NEXT -> viewModel.episodeSwipeUpLast(episode) - Settings.UpNextAction.PLAY_LAST -> viewModel.episodeSwipeUpNext(episode) - } - - binding?.episodesRecyclerView?.let { recyclerView -> - recyclerView.findViewHolderForAdapterPosition(index)?.let { - itemTouchHelper.clearView(recyclerView, it) - } - } - adapter?.notifyItemChanged(index) - } - private fun archiveAllPlayed() { val count = viewModel.archivePlayedCount() val buttonString = resources.getStringPlural(count = count, singular = LR.string.archive_episodes_singular, plural = LR.string.archive_episodes_plural) diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/UserEpisodeViewHolder.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/UserEpisodeViewHolder.kt index e06fc35c7c8..596a2a27c2c 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/UserEpisodeViewHolder.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/view/podcast/UserEpisodeViewHolder.kt @@ -33,6 +33,8 @@ import au.com.shiftyjelly.pocketcasts.ui.helper.ColorUtils import au.com.shiftyjelly.pocketcasts.utils.extensions.dpToPx import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper import au.com.shiftyjelly.pocketcasts.views.helper.RowSwipeable +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayout +import au.com.shiftyjelly.pocketcasts.views.helper.SwipeButtonLayoutFactory import com.jakewharton.rxrelay2.BehaviorRelay import io.reactivex.BackpressureStrategy import io.reactivex.Observable @@ -52,16 +54,19 @@ class UserEpisodeViewHolder( val downloadProgressUpdates: Observable, val playbackStateUpdates: Observable, val upNextChangesObservable: Observable, - val imageLoader: PodcastImageLoader? = null + val imageLoader: PodcastImageLoader? = null, + private val swipeButtonLayoutFactory: SwipeButtonLayoutFactory, ) : RecyclerView.ViewHolder(binding.root), RowSwipeable { override val episodeRow: ViewGroup get() = binding.episodeRow - override val swipeLeftIcon: ImageView - get() = binding.archiveIcon override val leftRightIcon1: ImageView get() = binding.leftRightIcon1 override val leftRightIcon2: ImageView get() = binding.leftRightIcon2 + override val rightLeftIcon1: ImageView + get() = binding.rightLeftIcon1 + override val rightLeftIcon2: ImageView + get() = binding.rightLeftIcon2 override val episode: BaseEpisode? get() = binding.episode override val positionAdapter: Int @@ -76,6 +81,8 @@ class UserEpisodeViewHolder( object Artwork : ViewMode() } + override lateinit var swipeButtonLayout: SwipeButtonLayout + val dateFormatter = RelativeDateFormatter(context) val context: Context get() = binding.root.context @@ -105,18 +112,20 @@ class UserEpisodeViewHolder( ) } } - override val rightIconDrawableRes: List - get() { - return if (episode?.isArchived == true) - listOf(EpisodeItemTouchHelper.IconWithBackground(VR.drawable.ic_delete, binding.episodeRow.context.getThemeColor(UR.attr.support_05))) - else - listOf(EpisodeItemTouchHelper.IconWithBackground(VR.drawable.ic_delete, binding.episodeRow.context.getThemeColor(UR.attr.support_05))) - } + override val rightIconDrawablesRes: List = + listOf( + EpisodeItemTouchHelper.IconWithBackground( + iconRes = VR.drawable.ic_delete, + backgroundColor = binding.episodeRow.context.getThemeColor(UR.attr.support_05) + ) + ) fun setup(episode: UserEpisode, tintColor: Int, playButtonListener: PlayButton.OnClickListener, streamByDefault: Boolean, upNextAction: Settings.UpNextAction, multiSelectEnabled: Boolean = false, isSelected: Boolean = false) { this.upNextAction = upNextAction this.isMultiSelecting = multiSelectEnabled + swipeButtonLayout = swipeButtonLayoutFactory.forEpisode(episode) + val playButtonType = PlayButton.calculateButtonType(episode, streamByDefault) binding.playButtonType = playButtonType binding.episode = episode diff --git a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt index e386deef594..212a3a11509 100644 --- a/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt +++ b/modules/features/podcasts/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/viewmodel/PodcastViewModel.kt @@ -11,7 +11,6 @@ import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper import au.com.shiftyjelly.pocketcasts.analytics.EpisodeAnalytics import au.com.shiftyjelly.pocketcasts.analytics.SourceView -import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode import au.com.shiftyjelly.pocketcasts.models.entity.Folder import au.com.shiftyjelly.pocketcasts.models.entity.Podcast import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode @@ -28,8 +27,6 @@ import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager import au.com.shiftyjelly.pocketcasts.servers.podcast.PodcastCacheServerManagerImpl import au.com.shiftyjelly.pocketcasts.ui.theme.Theme import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer -import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper.SwipeAction -import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper.SwipeSource import com.jakewharton.rxrelay2.BehaviorRelay import dagger.hilt.android.lifecycle.HiltViewModel import io.reactivex.BackpressureStrategy @@ -71,7 +68,6 @@ class PodcastViewModel private val disposables = CompositeDisposable() val podcast = MutableLiveData() var searchTerm = "" - var searchOpen = false lateinit var podcastUuid: String lateinit var episodes: LiveData val groupedEpisodes: MutableLiveData>> = MutableLiveData() @@ -286,47 +282,6 @@ class PodcastViewModel } } - @Suppress("UNUSED_PARAMETER") - fun episodeSwipeArchive(episode: BaseEpisode, index: Int) { - if (episode !is PodcastEpisode) return - - launch { - if (!episode.isArchived) { - episodeManager.archive(episode, playbackManager) - trackSwipeAction(SwipeAction.ARCHIVE) - trackEpisodeEvent(AnalyticsEvent.EPISODE_ARCHIVED, episode) - } else { - episodeManager.unarchive(episode) - trackSwipeAction(SwipeAction.UNARCHIVE) - trackEpisodeEvent(AnalyticsEvent.EPISODE_UNARCHIVED, episode) - } - } - } - - fun episodeSwipeUpNext(episode: BaseEpisode) { - launch { - if (playbackManager.upNextQueue.contains(episode.uuid)) { - playbackManager.removeEpisode(episodeToRemove = episode, source = SourceView.PODCAST_SCREEN) - trackSwipeAction(SwipeAction.UP_NEXT_REMOVE) - } else { - playbackManager.playNext(episode = episode, source = SourceView.PODCAST_SCREEN) - trackSwipeAction(SwipeAction.UP_NEXT_ADD_TOP) - } - } - } - - fun episodeSwipeUpLast(episode: BaseEpisode) { - launch { - if (playbackManager.upNextQueue.contains(episode.uuid)) { - playbackManager.removeEpisode(episodeToRemove = episode, source = SourceView.PODCAST_SCREEN) - trackSwipeAction(SwipeAction.UP_NEXT_REMOVE) - } else { - playbackManager.playLast(episode = episode, source = SourceView.PODCAST_SCREEN) - trackSwipeAction(SwipeAction.UP_NEXT_ADD_BOTTOM) - } - } - } - fun shouldShowArchiveAll(): Boolean { val episodes = (episodes.value as? EpisodeState.Loaded)?.episodes ?: return false return episodes.find { !it.isArchived } != null @@ -392,24 +347,6 @@ class PodcastViewModel } } - private fun trackSwipeAction(swipeAction: SwipeAction) { - analyticsTracker.track( - AnalyticsEvent.EPISODE_SWIPE_ACTION_PERFORMED, - AnalyticsProp.swipePerformed( - action = swipeAction, - source = SwipeSource.PODCAST_DETAILS - ) - - ) - } - private fun trackEpisodeEvent(event: AnalyticsEvent, episode: PodcastEpisode) { - episodeAnalytics.trackEvent( - event, - source = SourceView.PODCAST_SCREEN, - uuid = episode.uuid - ) - } - private fun trackEpisodeBulkEvent(event: AnalyticsEvent, count: Int) { episodeAnalytics.trackBulkEvent( event, @@ -444,7 +381,6 @@ class PodcastViewModel } private object AnalyticsProp { - private const val ACTION_KEY = "action" private const val ENABLED_KEY = "enabled" private const val SHOW_ARCHIVED = "show_archived" private const val SOURCE_KEY = "source" @@ -455,8 +391,6 @@ class PodcastViewModel mapOf(ENABLED_KEY to show) fun podcastSubscribeToggled(source: SourceView, uuid: String) = mapOf(SOURCE_KEY to source.analyticsValue, UUID_KEY to uuid) - fun swipePerformed(source: SwipeSource, action: SwipeAction) = - mapOf(SOURCE_KEY to source, ACTION_KEY to action.analyticsValue) } } diff --git a/modules/features/podcasts/src/main/res/layout/adapter_episode.xml b/modules/features/podcasts/src/main/res/layout/adapter_episode.xml index a57d45bfd95..da8bf615fe6 100644 --- a/modules/features/podcasts/src/main/res/layout/adapter_episode.xml +++ b/modules/features/podcasts/src/main/res/layout/adapter_episode.xml @@ -15,17 +15,37 @@ android:id="@+id/rightToLeftSwipeLayout" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?attr/support_06" + android:background="@color/green_90" android:visibility="gone" android:importantForAccessibility="noHideDescendants"> - + + + + + + + - + + + + + + + analyticsTracker.track(AnalyticsEvent.USER_FILE_DETAIL_SHOWN) @@ -129,7 +164,7 @@ class CloudFilesFragment : BaseFragment(), Toolbar.OnMenuItemClickListener { it.layoutManager = LinearLayoutManager(it.context, RecyclerView.VERTICAL, false) it.adapter = adapter (it.itemAnimator as SimpleItemAnimator).changeDuration = 0 - itemTouchHelper = EpisodeItemTouchHelper(this::episodeSwipedRightItem1, this::episodeSwipedRightItem2, this::episodeDeleteSwiped) + itemTouchHelper = EpisodeItemTouchHelper() itemTouchHelper.attachToRecyclerView(it) } @@ -353,36 +388,6 @@ class CloudFilesFragment : BaseFragment(), Toolbar.OnMenuItemClickListener { dialog.show(parentFragmentManager, "sort_options") } - private fun episodeDeleteSwiped(episode: BaseEpisode, index: Int) { - val userEpisode = episode as? UserEpisode ?: return - val deleteState = viewModel.getDeleteStateOnSwipeDelete(userEpisode) - val confirmationDialog = CloudDeleteHelper.getDeleteDialog(userEpisode, deleteState, viewModel::deleteEpisode, resources) - confirmationDialog - .setOnDismiss { - val recyclerView = binding?.recyclerView - recyclerView?.findViewHolderForAdapterPosition(index)?.let { - itemTouchHelper.clearView(recyclerView, it) - } - } - confirmationDialog.show(parentFragmentManager, "delete_confirm") - } - - private fun episodeSwipedRightItem1(episode: BaseEpisode, index: Int) { - when (settings.getUpNextSwipeAction()) { - Settings.UpNextAction.PLAY_NEXT -> viewModel.episodeSwipeUpNext(episode) - Settings.UpNextAction.PLAY_LAST -> viewModel.episodeSwipeUpLast(episode) - } - adapter.notifyItemChanged(index) - } - - private fun episodeSwipedRightItem2(episode: BaseEpisode, index: Int) { - when (settings.getUpNextSwipeAction()) { - Settings.UpNextAction.PLAY_NEXT -> viewModel.episodeSwipeUpLast(episode) - Settings.UpNextAction.PLAY_LAST -> viewModel.episodeSwipeUpNext(episode) - } - adapter.notifyItemChanged(index) - } - override fun onBackPressed(): Boolean { return if (multiSelectHelper.isMultiSelecting) { multiSelectHelper.isMultiSelecting = false diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudFilesViewModel.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudFilesViewModel.kt index 50019d56d74..258722c17ba 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudFilesViewModel.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/cloud/CloudFilesViewModel.kt @@ -4,36 +4,21 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper -import au.com.shiftyjelly.pocketcasts.analytics.EpisodeAnalytics -import au.com.shiftyjelly.pocketcasts.analytics.SourceView -import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode -import au.com.shiftyjelly.pocketcasts.models.entity.UserEpisode import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.repositories.file.CloudFilesManager import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager -import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.UserEpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager -import au.com.shiftyjelly.pocketcasts.views.helper.CloudDeleteHelper -import au.com.shiftyjelly.pocketcasts.views.helper.DeleteState -import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper.SwipeAction -import au.com.shiftyjelly.pocketcasts.views.helper.EpisodeItemTouchHelper.SwipeSource import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class CloudFilesViewModel @Inject constructor( private val userEpisodeManager: UserEpisodeManager, private val playbackManager: PlaybackManager, - private val episodeManager: EpisodeManager, private val settings: Settings, userManager: UserManager, private val analyticsTracker: AnalyticsTrackerWrapper, - private val episodeAnalytics: EpisodeAnalytics, private val cloudFilesManager: CloudFilesManager, ) : ViewModel() { @@ -57,46 +42,6 @@ class CloudFilesViewModel @Inject constructor( userEpisodeManager.syncFilesInBackground(playbackManager) } - @OptIn(DelicateCoroutinesApi::class) - fun episodeSwipeUpNext(episode: BaseEpisode) { - GlobalScope.launch(Dispatchers.Default) { - if (playbackManager.upNextQueue.contains(episode.uuid)) { - playbackManager.removeEpisode(episodeToRemove = episode, source = SourceView.FILES) - trackSwipeAction(SwipeAction.UP_NEXT_REMOVE) - } else { - playbackManager.playNext(episode = episode, source = SourceView.FILES) - trackSwipeAction(SwipeAction.UP_NEXT_ADD_TOP) - } - } - } - - @OptIn(DelicateCoroutinesApi::class) - fun episodeSwipeUpLast(episode: BaseEpisode) { - GlobalScope.launch(Dispatchers.Default) { - if (playbackManager.upNextQueue.contains(episode.uuid)) { - playbackManager.removeEpisode(episodeToRemove = episode, source = SourceView.FILES) - trackSwipeAction(SwipeAction.UP_NEXT_REMOVE) - } else { - playbackManager.playLast(episode = episode, source = SourceView.FILES) - trackSwipeAction(SwipeAction.UP_NEXT_ADD_BOTTOM) - } - } - } - - fun getDeleteStateOnSwipeDelete(episode: UserEpisode): DeleteState { - trackSwipeAction(SwipeAction.DELETE) - return CloudDeleteHelper.getDeleteState(episode) - } - - fun deleteEpisode(episode: UserEpisode, deleteState: DeleteState) { - CloudDeleteHelper.deleteEpisode(episode, deleteState, playbackManager, episodeManager, userEpisodeManager) - episodeAnalytics.trackEvent( - event = if (deleteState == DeleteState.Cloud && !episode.isDownloaded) AnalyticsEvent.EPISODE_DELETED_FROM_CLOUD else AnalyticsEvent.EPISODE_DOWNLOAD_DELETED, - source = SourceView.FILES, - uuid = episode.uuid, - ) - } - fun changeSort(sortOrder: Settings.CloudSortOrder) { settings.setCloudSortOrder(sortOrder) cloudFilesManager.sortOrderRelay.accept(sortOrder) @@ -106,16 +51,6 @@ class CloudFilesViewModel @Inject constructor( return cloudFilesManager.sortOrderRelay.value ?: settings.getCloudSortOrder() } - private fun trackSwipeAction(swipeAction: SwipeAction) { - analyticsTracker.track( - AnalyticsEvent.EPISODE_SWIPE_ACTION_PERFORMED, - mapOf( - ACTION_KEY to swipeAction.analyticsValue, - SOURCE_KEY to SwipeSource.FILES.analyticsValue - ) - ) - } - companion object { private const val ACTION_KEY = "action" private const val SOURCE_KEY = "source" diff --git a/modules/services/analytics/src/main/java/au/com/shiftyjelly/pocketcasts/analytics/EpisodeAnalytics.kt b/modules/services/analytics/src/main/java/au/com/shiftyjelly/pocketcasts/analytics/EpisodeAnalytics.kt index e2ffc367f78..e72130a8526 100644 --- a/modules/services/analytics/src/main/java/au/com/shiftyjelly/pocketcasts/analytics/EpisodeAnalytics.kt +++ b/modules/services/analytics/src/main/java/au/com/shiftyjelly/pocketcasts/analytics/EpisodeAnalytics.kt @@ -38,8 +38,16 @@ class EpisodeAnalytics @Inject constructor( analyticsTracker.track(event, AnalyticsProp.uuidMap(uuid)) } - fun trackEvent(event: AnalyticsEvent, source: SourceView, toTop: Boolean) { - analyticsTracker.track(event, AnalyticsProp.sourceAndToTopMap(source, toTop)) + fun trackEvent( + event: AnalyticsEvent, + source: SourceView, + toTop: Boolean, + episode: BaseEpisode, + ) { + analyticsTracker.track( + event, + AnalyticsProp.sourceAndToTopMap(source, toTop, episode) + ) } fun trackBulkEvent(event: AnalyticsEvent, source: SourceView, count: Int) { @@ -72,8 +80,12 @@ class EpisodeAnalytics @Inject constructor( mapOf(source to eventSource.analyticsValue, episode_uuid to uuid) fun uuidMap(uuid: String) = mapOf(episode_uuid to uuid) - fun sourceAndToTopMap(eventSource: SourceView, toTop: Boolean) = - mapOf(source to eventSource.analyticsValue, to_top to toTop) + fun sourceAndToTopMap(eventSource: SourceView, toTop: Boolean, episode: BaseEpisode) = + mapOf( + source to eventSource.analyticsValue, + to_top to toTop, + episode_uuid to episode.uuid, + ) fun bulkMap(eventSource: SourceView, count: Int) = mapOf(source to eventSource.analyticsValue, this.count to count) diff --git a/modules/services/analytics/src/main/java/au/com/shiftyjelly/pocketcasts/analytics/SourceView.kt b/modules/services/analytics/src/main/java/au/com/shiftyjelly/pocketcasts/analytics/SourceView.kt index f3ae0f308f0..6eaa812eb64 100644 --- a/modules/services/analytics/src/main/java/au/com/shiftyjelly/pocketcasts/analytics/SourceView.kt +++ b/modules/services/analytics/src/main/java/au/com/shiftyjelly/pocketcasts/analytics/SourceView.kt @@ -29,7 +29,9 @@ enum class SourceView(val analyticsValue: String) { ONBOARDING_RECOMMENDATIONS("onboarding_recommendations"), ONBOARDING_RECOMMENDATIONS_SEARCH("onboarding_recommendations_search"), UNKNOWN("unknown"), - TASKER("tasker"); + TASKER("tasker"), + EPISODE_SWIPE_ACTION("episode_swipe_action"), + MULTI_SELECT("multi_select"); fun skipTracking() = this in listOf(AUTO_PLAY, AUTO_PAUSE) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt index c953b2de07c..6019e7abbc2 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt @@ -428,6 +428,7 @@ open class PlaybackManager @Inject constructor( SourceView.DISCOVER_RANKED_LIST, SourceView.FULL_SCREEN_VIDEO, SourceView.MINIPLAYER, + SourceView.MULTI_SELECT, SourceView.ONBOARDING_RECOMMENDATIONS, SourceView.ONBOARDING_RECOMMENDATIONS_SEARCH, SourceView.PODCAST_LIST, @@ -435,6 +436,7 @@ open class PlaybackManager @Inject constructor( SourceView.PLAYER, SourceView.PLAYER_BROADCAST_ACTION, SourceView.PLAYER_PLAYBACK_EFFECTS, + SourceView.EPISODE_SWIPE_ACTION, SourceView.TASKER, SourceView.UNKNOWN, SourceView.UP_NEXT, @@ -478,7 +480,7 @@ open class PlaybackManager @Inject constructor( val wasEmpty: Boolean = upNextQueue.isEmpty upNextQueue.playNext(episode, downloadManager, null) if (userInitiated) { - episodeAnalytics.trackEvent(AnalyticsEvent.EPISODE_ADDED_TO_UP_NEXT, source, true) + episodeAnalytics.trackEvent(AnalyticsEvent.EPISODE_ADDED_TO_UP_NEXT, source, true, episode) } if (wasEmpty) { loadCurrentEpisode(play = false) @@ -493,7 +495,7 @@ open class PlaybackManager @Inject constructor( val wasEmpty: Boolean = upNextQueue.isEmpty upNextQueue.playLast(episode, downloadManager, null) if (userInitiated) { - episodeAnalytics.trackEvent(AnalyticsEvent.EPISODE_ADDED_TO_UP_NEXT, source, false) + episodeAnalytics.trackEvent(AnalyticsEvent.EPISODE_ADDED_TO_UP_NEXT, source, false, episode) } if (wasEmpty) { loadCurrentEpisode(play = false) diff --git a/modules/services/ui/src/main/res/values/ids.xml b/modules/services/ui/src/main/res/values/ids.xml index 580e45df1e9..f1c0b43bfce 100644 --- a/modules/services/ui/src/main/res/values/ids.xml +++ b/modules/services/ui/src/main/res/values/ids.xml @@ -4,9 +4,11 @@ + + - \ No newline at end of file + diff --git a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/dialog/ShareDialog.kt b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/dialog/ShareDialog.kt index d4b8e2828f2..7c245c74238 100644 --- a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/dialog/ShareDialog.kt +++ b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/dialog/ShareDialog.kt @@ -1,5 +1,6 @@ package au.com.shiftyjelly.pocketcasts.views.dialog +import android.app.Application import android.content.Context import androidx.fragment.app.FragmentManager import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper @@ -20,8 +21,17 @@ class ShareDialog( private val analyticsTracker: AnalyticsTrackerWrapper ) { - private val source = SourceView.EPISODE_DETAILS - fun show() { + init { + if (context is Application) { + // Cannot use application context here because it will cause a crash when + // the show method tries to start a new activity. + throw IllegalArgumentException("ShareDialog cannot use the application context") + } + } + + // If the share dialog is not appearing, make sure you're setting an appropriate fragmentManager + // when constructing this class, i.e., you might need a parentFragmentManager instead of a childFragmentManager + fun show(sourceView: SourceView) { if (fragmentManager == null || context == null) { return } @@ -37,7 +47,7 @@ class ShareDialog( null, context, ShareType.PODCAST, - source, + sourceView, analyticsTracker ).showShareDialogDirect() } @@ -53,7 +63,7 @@ class ShareDialog( null, context, ShareType.EPISODE, - source, + sourceView, analyticsTracker ).showShareDialogDirect() } @@ -67,7 +77,7 @@ class ShareDialog( episode.playedUpTo, context, ShareType.CURRENT_TIME, - source, + sourceView, analyticsTracker ).showShareDialogDirect() } @@ -82,7 +92,7 @@ class ShareDialog( episode.playedUpTo, context, ShareType.EPISODE_FILE, - source, + sourceView, analyticsTracker ).sendFile() } diff --git a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/MultiSwipeHelper.kt b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/MultiSwipeHelper.kt index 7006b73703e..6314910d196 100644 --- a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/MultiSwipeHelper.kt +++ b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/MultiSwipeHelper.kt @@ -483,7 +483,7 @@ open class MultiSwipeHelper(internal var mCallback: Callback) : RecyclerView.Ite if (abs(currentTranslateX) > mRecyclerView!!.width * mCallback.getMultiItemCutoffThreshold()) { targetTranslateX = Math.signum(mDx) * mRecyclerView!!.width } else { - targetTranslateX = -mCallback.getMultiItemStopSize(mSelected!!) + targetTranslateX = -mCallback.getMultiItemStopSize(mSelected!!, SwipeDirection.Left) } } RIGHT, END -> { @@ -491,7 +491,7 @@ open class MultiSwipeHelper(internal var mCallback: Callback) : RecyclerView.Ite if (currentTranslateX > mRecyclerView!!.width * mCallback.getMultiItemCutoffThreshold()) { targetTranslateX = Math.signum(mDx) * mRecyclerView!!.width } else { - targetTranslateX = mCallback.getMultiItemStopSize(mSelected!!) + targetTranslateX = mCallback.getMultiItemStopSize(mSelected!!, SwipeDirection.Right) } } UP, DOWN -> { @@ -1485,7 +1485,7 @@ open class MultiSwipeHelper(internal var mCallback: Callback) : RecyclerView.Ite return .4f } - abstract fun getMultiItemStopSize(viewHolder: ViewHolder): Float + abstract fun getMultiItemStopSize(viewHolder: ViewHolder, swipeDirection: SwipeDirection): Float open fun augmentUpdateDxDy(dx: Float, dy: Float): Pair { return Pair(dx, dy) diff --git a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeButtonLayout.kt b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeButtonLayout.kt new file mode 100644 index 00000000000..fbd6ca2dcc3 --- /dev/null +++ b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeButtonLayout.kt @@ -0,0 +1,213 @@ +package au.com.shiftyjelly.pocketcasts.views.helper + +import android.content.Context +import androidx.fragment.app.FragmentManager +import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode +import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode +import au.com.shiftyjelly.pocketcasts.models.entity.UserEpisode +import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.ui.extensions.getThemeColor +import au.com.shiftyjelly.pocketcasts.images.R as IR +import au.com.shiftyjelly.pocketcasts.ui.R as UR +import au.com.shiftyjelly.pocketcasts.views.R as VR + +data class SwipeButtonLayout( + val leftPrimary: () -> SwipeButton, + val leftSecondary: () -> SwipeButton?, + val rightPrimary: () -> SwipeButton, + val rightSecondary: () -> SwipeButton?, +) + +typealias RowIndex = Int + +sealed interface SwipeButton { + val iconRes: Int + val backgroundColor: (Context) -> Int + val onClick: (BaseEpisode, RowIndex) -> Unit + + // Shows the remove from up next button if the episode is already queued + class AddToUpNextTop( + private val onItemUpdated: (BaseEpisode, RowIndex) -> Unit, + private val swipeSource: EpisodeItemTouchHelper.SwipeSource, + private val viewModel: SwipeButtonLayoutViewModel, + ) : SwipeButton { + + override val iconRes = IR.drawable.ic_upnext_movetotop + + override val backgroundColor: (Context) -> Int = { it.getThemeColor(UR.attr.support_04) } + + override val onClick: (BaseEpisode, RowIndex) -> Unit + get() = { baseEpisode, rowIndex -> + viewModel.episodeSwipeUpNextTop( + episode = baseEpisode, + swipeSource = swipeSource, + ) + onItemUpdated(baseEpisode, rowIndex) + } + } + + // Shows the remove from up next button if the episode is already queued + class AddToUpNextBottom( + private val onItemUpdated: (BaseEpisode, RowIndex) -> Unit, + private val swipeSource: EpisodeItemTouchHelper.SwipeSource, + private val viewModel: SwipeButtonLayoutViewModel, + ) : SwipeButton { + + override val iconRes = IR.drawable.ic_upnext_movetobottom + + override val backgroundColor: (Context) -> Int = { + it.getThemeColor(UR.attr.support_03) + } + + override val onClick: (BaseEpisode, RowIndex) -> Unit = { baseEpisode, rowIndex -> + viewModel.episodeSwipeUpNextBottom( + episode = baseEpisode, + swipeSource = swipeSource, + ) + onItemUpdated(baseEpisode, rowIndex) + } + } + + class RemoveFromUpNext( + private val onItemUpdated: (BaseEpisode, RowIndex) -> Unit, + private val swipeSource: EpisodeItemTouchHelper.SwipeSource, + private val viewModel: SwipeButtonLayoutViewModel, + ) : SwipeButton { + + override val iconRes = IR.drawable.ic_upnext_remove + + override val backgroundColor: (Context) -> Int = { it.getThemeColor(UR.attr.support_05) } + + override val onClick: (BaseEpisode, RowIndex) -> Unit = { baseEpisode, rowIndex -> + viewModel.episodeSwipeRemoveUpNext( + episode = baseEpisode, + swipeSource = swipeSource, + ) + onItemUpdated(baseEpisode, rowIndex) + } + } + + class DeleteFileButton( + private val onItemModified: (UserEpisode, RowIndex) -> Unit, + private val swipeSource: EpisodeItemTouchHelper.SwipeSource, + private val fragmentManager: FragmentManager, + private val viewModel: SwipeButtonLayoutViewModel, + ) : SwipeButton { + + override val iconRes = VR.drawable.ic_delete + + override val backgroundColor: (Context) -> Int = + { it.getThemeColor(UR.attr.support_05) } + + override val onClick: (BaseEpisode, RowIndex) -> Unit = { episode, rowIndex -> + if (episode !is UserEpisode) { + throw IllegalStateException("Can only delete user episodes, but tried to delete: $episode") + } + viewModel.deleteEpisode( + episode = episode, + swipeSource = swipeSource, + fragmentManager = fragmentManager, + onDismiss = { onItemModified(episode, rowIndex) } + ) + } + } + + class ArchiveButton( + private val episode: BaseEpisode, + private val onItemUpdated: (BaseEpisode, RowIndex) -> Unit, + private val swipeSource: EpisodeItemTouchHelper.SwipeSource, + private val viewModel: SwipeButtonLayoutViewModel, + ) : SwipeButton { + + override val iconRes + get() = if (episode.isArchived) { + IR.drawable.ic_unarchive + } else { + IR.drawable.ic_archive + } + + override val backgroundColor: (Context) -> Int = + { it.getThemeColor(UR.attr.support_06) } + + override val onClick: (BaseEpisode, RowIndex) -> Unit = { episode, rowIndex -> + if (episode !is PodcastEpisode) { + throw IllegalStateException("Can only share podcast episodes, but tried to archive: $episode") + } + viewModel.updateArchive(episode, swipeSource) + onItemUpdated(episode, rowIndex) + } + } + + class ShareButton( + swipeSource: EpisodeItemTouchHelper.SwipeSource, + fragmentManager: FragmentManager, + context: Context, + viewModel: SwipeButtonLayoutViewModel, + ) : SwipeButton { + + override val iconRes = IR.drawable.ic_share + + override val backgroundColor: (Context) -> Int = + { it.getThemeColor(UR.attr.support_01) } + + override val onClick: (BaseEpisode, RowIndex) -> Unit = { episode, _ -> + if (episode !is PodcastEpisode) { + throw IllegalStateException("Can only share podcast episodes: $episode") + } + viewModel.share(episode, fragmentManager, context, swipeSource) + } + } +} + +class SwipeButtonLayoutFactory( + private val swipeButtonLayoutViewModel: SwipeButtonLayoutViewModel, + private val onItemUpdated: (BaseEpisode, RowIndex) -> Unit, + private val showShareButton: Boolean = true, + private val defaultUpNextSwipeAction: () -> Settings.UpNextAction, + private val context: Context, + private val fragmentManager: FragmentManager, + private val swipeSource: EpisodeItemTouchHelper.SwipeSource, +) { + fun forEpisode(episode: BaseEpisode): SwipeButtonLayout = + swipeButtonLayoutViewModel.getSwipeButtonLayout( + episode = episode, + swipeSource = swipeSource, + defaultUpNextSwipeAction = defaultUpNextSwipeAction, + showShareButton = showShareButton, + buttons = SwipeButtonLayoutViewModel.SwipeButtons( + addToUpNextTop = SwipeButton.AddToUpNextTop( + onItemUpdated = onItemUpdated, + swipeSource = swipeSource, + viewModel = swipeButtonLayoutViewModel, + ), + addToUpNextBottom = SwipeButton.AddToUpNextBottom( + onItemUpdated = onItemUpdated, + swipeSource = swipeSource, + viewModel = swipeButtonLayoutViewModel, + ), + removeFromUpNext = SwipeButton.RemoveFromUpNext( + onItemUpdated = onItemUpdated, + swipeSource = swipeSource, + viewModel = swipeButtonLayoutViewModel, + ), + archive = SwipeButton.ArchiveButton( + episode = episode, + onItemUpdated = onItemUpdated, + swipeSource = swipeSource, + viewModel = swipeButtonLayoutViewModel, + ), + deleteFile = SwipeButton.DeleteFileButton( + onItemModified = onItemUpdated, + swipeSource = swipeSource, + fragmentManager = fragmentManager, + viewModel = swipeButtonLayoutViewModel, + ), + share = SwipeButton.ShareButton( + swipeSource = swipeSource, + fragmentManager = fragmentManager, + context = context, + viewModel = swipeButtonLayoutViewModel, + ), + ), + ) +} diff --git a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeButtonLayoutViewModel.kt b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeButtonLayoutViewModel.kt new file mode 100644 index 00000000000..c1fc73666ac --- /dev/null +++ b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeButtonLayoutViewModel.kt @@ -0,0 +1,259 @@ +package au.com.shiftyjelly.pocketcasts.views.helper + +import android.content.Context +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent +import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper +import au.com.shiftyjelly.pocketcasts.analytics.EpisodeAnalytics +import au.com.shiftyjelly.pocketcasts.analytics.SourceView +import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode +import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode +import au.com.shiftyjelly.pocketcasts.models.entity.UserEpisode +import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager +import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager +import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager +import au.com.shiftyjelly.pocketcasts.repositories.podcast.UserEpisodeManager +import au.com.shiftyjelly.pocketcasts.views.dialog.ShareDialog +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SwipeButtonLayoutViewModel @Inject constructor( + private val analyticsTracker: AnalyticsTrackerWrapper, + @ApplicationContext private val context: Context, + private val episodeAnalytics: EpisodeAnalytics, + private val episodeManager: EpisodeManager, + private val playbackManager: PlaybackManager, + private val podcastManager: PodcastManager, + private val userEpisodeManager: UserEpisodeManager, +) : ViewModel() { + + fun share( + episode: PodcastEpisode, + fragmentManager: FragmentManager, + context: Context, + swipeSource: EpisodeItemTouchHelper.SwipeSource + ) { + + viewModelScope.launch { + + trackSwipeAction( + swipeSource = swipeSource, + swipeAction = EpisodeItemTouchHelper.SwipeAction.SHARE, + ) + + val podcast = podcastManager.findPodcastByUuidSuspend(episode.podcastUuid) ?: return@launch + + ShareDialog( + episode = episode, + podcast = podcast, + fragmentManager = fragmentManager, + context = context, + shouldShowPodcast = false, + analyticsTracker = analyticsTracker, + ).show(sourceView = SourceView.EPISODE_SWIPE_ACTION) + } + } + + fun trackSwipeAction( + swipeSource: EpisodeItemTouchHelper.SwipeSource, + swipeAction: EpisodeItemTouchHelper.SwipeAction, + ) { + analyticsTracker.track( + AnalyticsEvent.EPISODE_SWIPE_ACTION_PERFORMED, + mapOf( + "action" to swipeAction.analyticsValue, + "source" to swipeSource.analyticsValue + ) + ) + } + + fun episodeSwipeUpNextTop( + episode: BaseEpisode, + swipeSource: EpisodeItemTouchHelper.SwipeSource, + ) { + viewModelScope.launch(Dispatchers.Default) { + trackSwipeAction( + swipeSource = swipeSource, + swipeAction = EpisodeItemTouchHelper.SwipeAction.UP_NEXT_ADD_TOP + ) + playbackManager.playNext( + episode = episode, + source = swipeSourceToSourceView(swipeSource) + ) + } + } + + fun episodeSwipeUpNextBottom( + episode: BaseEpisode, + swipeSource: EpisodeItemTouchHelper.SwipeSource, + ) { + viewModelScope.launch(Dispatchers.Default) { + trackSwipeAction( + swipeSource = swipeSource, + swipeAction = EpisodeItemTouchHelper.SwipeAction.UP_NEXT_ADD_BOTTOM + ) + playbackManager.playLast( + episode = episode, + source = swipeSourceToSourceView(swipeSource) + ) + } + } + + fun episodeSwipeRemoveUpNext( + episode: BaseEpisode, + swipeSource: EpisodeItemTouchHelper.SwipeSource, + ) { + viewModelScope.launch(Dispatchers.Default) { + trackSwipeAction( + swipeSource = swipeSource, + swipeAction = EpisodeItemTouchHelper.SwipeAction.UP_NEXT_REMOVE + ) + playbackManager.removeEpisode( + episodeToRemove = episode, + source = swipeSourceToSourceView(swipeSource) + ) + } + } + + fun updateArchive(episode: PodcastEpisode, swipeSource: EpisodeItemTouchHelper.SwipeSource) { + viewModelScope.launch(Dispatchers.Default) { + if (!episode.isArchived) { + trackSwipeAction(swipeSource, EpisodeItemTouchHelper.SwipeAction.ARCHIVE) + episodeManager.archive(episode, playbackManager) + episodeAnalytics.trackEvent( + event = AnalyticsEvent.EPISODE_ARCHIVED, + source = swipeSourceToSourceView(swipeSource), + uuid = episode.uuid + ) + } else { + trackSwipeAction(swipeSource, EpisodeItemTouchHelper.SwipeAction.UNARCHIVE) + episodeManager.unarchive(episode) + episodeAnalytics.trackEvent( + event = AnalyticsEvent.EPISODE_UNARCHIVED, + source = swipeSourceToSourceView(swipeSource), + uuid = episode.uuid + ) + } + } + } + + fun deleteEpisode( + episode: UserEpisode, + swipeSource: EpisodeItemTouchHelper.SwipeSource, + fragmentManager: FragmentManager, + onDismiss: () -> Unit, + ) { + trackSwipeAction(swipeSource, EpisodeItemTouchHelper.SwipeAction.DELETE) + CloudDeleteHelper.getDeleteDialog( + episode = episode, + deleteState = CloudDeleteHelper.getDeleteState(episode), + deleteFunction = { userEpisode, deleteState -> + CloudDeleteHelper.deleteEpisode( + episode = userEpisode, + deleteState = deleteState, + playbackManager = playbackManager, + episodeManager = episodeManager, + userEpisodeManager = userEpisodeManager, + ) + episodeAnalytics.trackEvent( + event = if (deleteState == DeleteState.Cloud && !episode.isDownloaded) AnalyticsEvent.EPISODE_DELETED_FROM_CLOUD else AnalyticsEvent.EPISODE_DOWNLOAD_DELETED, + source = SourceView.FILES, + uuid = episode.uuid, + ) + }, + resources = context.resources + ).apply { + setOnDismiss { onDismiss() } + show(fragmentManager, "delete_confirm") + } + } + + private fun isEpisodeQueued(episode: BaseEpisode) = playbackManager.upNextQueue.contains(episode.uuid) + + private fun swipeSourceToSourceView(swipeSource: EpisodeItemTouchHelper.SwipeSource) = when (swipeSource) { + EpisodeItemTouchHelper.SwipeSource.PODCAST_DETAILS -> SourceView.PODCAST_SCREEN + EpisodeItemTouchHelper.SwipeSource.FILTERS -> SourceView.FILTERS + EpisodeItemTouchHelper.SwipeSource.DOWNLOADS -> SourceView.DOWNLOADS + EpisodeItemTouchHelper.SwipeSource.LISTENING_HISTORY -> SourceView.LISTENING_HISTORY + EpisodeItemTouchHelper.SwipeSource.STARRED -> SourceView.STARRED + EpisodeItemTouchHelper.SwipeSource.FILES -> SourceView.FILES + EpisodeItemTouchHelper.SwipeSource.UP_NEXT -> SourceView.UP_NEXT + } + + fun getSwipeButtonLayout( + episode: BaseEpisode, + swipeSource: EpisodeItemTouchHelper.SwipeSource, + showShareButton: Boolean, + defaultUpNextSwipeAction: () -> Settings.UpNextAction, + buttons: SwipeButtons, + ): SwipeButtonLayout { + + val onUpNextQueueScreen = swipeSource == EpisodeItemTouchHelper.SwipeSource.UP_NEXT + + return if (onUpNextQueueScreen) { + SwipeButtonLayout( + // We ignore the user's swipe preference setting when on the up next screen + leftPrimary = { buttons.addToUpNextTop }, + leftSecondary = { buttons.addToUpNextBottom }, + rightPrimary = { buttons.removeFromUpNext }, + rightSecondary = { null }, + ) + } else { + SwipeButtonLayout( + leftPrimary = { + if (isEpisodeQueued(episode)) { + buttons.removeFromUpNext + } else { + // The left primary button is the action that is taken when the user swipes to the right + when (defaultUpNextSwipeAction()) { + Settings.UpNextAction.PLAY_NEXT -> buttons.addToUpNextTop + Settings.UpNextAction.PLAY_LAST -> buttons.addToUpNextBottom + } + } + }, + leftSecondary = { + if (isEpisodeQueued(episode)) { + // Do not show a secondary button on the left when episode queued + null + } else { + when (defaultUpNextSwipeAction()) { + Settings.UpNextAction.PLAY_NEXT -> buttons.addToUpNextBottom + Settings.UpNextAction.PLAY_LAST -> buttons.addToUpNextTop + } + } + }, + rightPrimary = { + when (episode) { + is UserEpisode -> buttons.deleteFile + is PodcastEpisode -> buttons.archive + } + }, + rightSecondary = { + when (episode) { + is UserEpisode -> null + is PodcastEpisode -> + if (showShareButton) { + buttons.share + } else null + } + }, + ) + } + } + + data class SwipeButtons( + val addToUpNextTop: SwipeButton.AddToUpNextTop, + val addToUpNextBottom: SwipeButton.AddToUpNextBottom, + val removeFromUpNext: SwipeButton.RemoveFromUpNext, + val archive: SwipeButton.ArchiveButton, + val deleteFile: SwipeButton.DeleteFileButton, + val share: SwipeButton.ShareButton, + ) +} diff --git a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeDirection.kt b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeDirection.kt new file mode 100644 index 00000000000..be287483132 --- /dev/null +++ b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeDirection.kt @@ -0,0 +1,6 @@ +package au.com.shiftyjelly.pocketcasts.views.helper + +enum class SwipeDirection { + Left, + Right +} diff --git a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeToArchiveCallback.kt b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeToArchiveCallback.kt index c41a0dc7957..7ff7b05b60b 100644 --- a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeToArchiveCallback.kt +++ b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeToArchiveCallback.kt @@ -25,55 +25,86 @@ import au.com.shiftyjelly.pocketcasts.ui.R as UR interface RowSwipeable { val episodeRow: ViewGroup val episode: BaseEpisode? - val swipeLeftIcon: ImageView val positionAdapter: Int val leftRightIcon1: ImageView val leftRightIcon2: ImageView + val rightLeftIcon1: ImageView + val rightLeftIcon2: ImageView val isMultiSelecting: Boolean val rightToLeftSwipeLayout: ViewGroup val leftToRightSwipeLayout: ViewGroup val upNextAction: Settings.UpNextAction val leftIconDrawablesRes: List - val rightIconDrawableRes: List + val rightIconDrawablesRes: List + val swipeButtonLayout: SwipeButtonLayout } -class EpisodeItemTouchHelper(onLeftItem1: (episode: BaseEpisode, index: Int) -> Unit, onLeftItem2: (episode: BaseEpisode, index: Int) -> Unit, onSwipeLeftAction: (episode: BaseEpisode, index: Int) -> Unit) : MultiSwipeHelper(object : SwipeToArchiveCallback() { - override fun onSwiped(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, direction: Int): Boolean { +class EpisodeItemTouchHelper constructor() : MultiSwipeHelper(object : SwipeToArchiveCallback() { + override fun onSwiped( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + direction: Int + ): Boolean { val episodeViewHolder = viewHolder as? RowSwipeable ?: return false val rowTranslation = episodeViewHolder.episodeRow.translationX val episode = episodeViewHolder.episode ?: return false val multiItemCutoff = episodeViewHolder.episodeRow.width * getMultiItemCutoffThreshold() + val rowIndex = episodeViewHolder.positionAdapter if (direction == ItemTouchHelper.LEFT && rowTranslation < 0) { if (abs(rowTranslation) > multiItemCutoff) { - onSwipeLeftAction(episode, episodeViewHolder.positionAdapter) + episodeViewHolder.swipeButtonLayout.rightPrimary().onClick(episode, rowIndex) clearView(recyclerView, viewHolder) return true } else { - episodeViewHolder.swipeLeftIcon.setOnClickListener { - onSwipeLeftAction(episode, episodeViewHolder.positionAdapter) - clearView(recyclerView, viewHolder) + episodeViewHolder.rightLeftIcon1.apply { + setOnClickListener { + episodeViewHolder.swipeButtonLayout.rightPrimary().onClick( + episode, + rowIndex + ) + clearView(recyclerView, viewHolder) + } + setRippleBackground(true) + } + episodeViewHolder.rightLeftIcon2.apply { + setOnClickListener { + episodeViewHolder.swipeButtonLayout.rightSecondary()?.onClick?.invoke( + episode, + rowIndex + ) + clearView(recyclerView, viewHolder) + } + setRippleBackground(true) } - episodeViewHolder.swipeLeftIcon.setRippleBackground(true) return false } } else if (direction == ItemTouchHelper.RIGHT && rowTranslation > 0) { if (rowTranslation > multiItemCutoff) { - onLeftItem1(episode, episodeViewHolder.positionAdapter) + episodeViewHolder.swipeButtonLayout.leftPrimary().onClick(episode, rowIndex) clearView(recyclerView, viewHolder) return true } else { - episodeViewHolder.leftRightIcon1.setOnClickListener { - onLeftItem1(episode, episodeViewHolder.positionAdapter) - clearView(recyclerView, viewHolder) + episodeViewHolder.leftRightIcon1.apply { + setOnClickListener { + episodeViewHolder.swipeButtonLayout.leftPrimary().onClick( + episode, + rowIndex + ) + clearView(recyclerView, viewHolder) + } + setRippleBackground(true) } - episodeViewHolder.leftRightIcon1.setRippleBackground(true) - episodeViewHolder.leftRightIcon2.setOnClickListener { - onLeftItem2(episode, episodeViewHolder.positionAdapter) - clearView(recyclerView, viewHolder) + episodeViewHolder.swipeButtonLayout.leftSecondary()?.onClick?.let { onClick -> + episodeViewHolder.leftRightIcon2.apply { + setOnClickListener { + onClick(episode, rowIndex) + clearView(recyclerView, viewHolder) + } + setRippleBackground(true) + } } - episodeViewHolder.leftRightIcon2.setRippleBackground(true) return false } } else { @@ -84,12 +115,18 @@ class EpisodeItemTouchHelper(onLeftItem1: (episode: BaseEpisode, index: Int) -> override fun getClickableViews(viewHolder: RecyclerView.ViewHolder): List { return if (viewHolder is RowSwipeable) { - listOf(viewHolder.leftRightIcon1, viewHolder.leftRightIcon2, viewHolder.swipeLeftIcon) + listOf( + viewHolder.leftRightIcon1, + viewHolder.leftRightIcon2, + viewHolder.rightLeftIcon1, + viewHolder.rightLeftIcon2, + ) } else { emptyList() } } }) { + data class IconWithBackground( @DrawableRes val iconRes: Int, @ColorInt val backgroundColor: Int @@ -112,6 +149,7 @@ class EpisodeItemTouchHelper(onLeftItem1: (episode: BaseEpisode, index: Int) -> DELETE("delete"), UNARCHIVE("unarchive"), ARCHIVE("archive"), + SHARE("share") } enum class SwipeSource(val analyticsValue: String) { @@ -125,9 +163,13 @@ class EpisodeItemTouchHelper(onLeftItem1: (episode: BaseEpisode, index: Int) -> } } -private abstract class SwipeToArchiveCallback() : MultiSwipeHelper.SimpleCallback(0, ItemTouchHelper.LEFT.or(ItemTouchHelper.RIGHT)) { +private abstract class SwipeToArchiveCallback : MultiSwipeHelper.SimpleCallback(0, ItemTouchHelper.LEFT.or(ItemTouchHelper.RIGHT)) { var swipeDirection: Int? = 0 + companion object { + private const val maxButtonWidth = 140 + } + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { if (viewHolder !is RowSwipeable) { return 0 @@ -150,8 +192,22 @@ private abstract class SwipeToArchiveCallback() : MultiSwipeHelper.SimpleCallbac ItemTouchHelper.Callback.getDefaultUIUtil().onSelected(leftToRightLayout) } - override fun getMultiItemStopSize(viewHolder: RecyclerView.ViewHolder): Float { - return if (swipeDirection ?: 0 > 0) 140.dpToPx(viewHolder.itemView.context).toFloat() else 70.dpToPx(viewHolder.itemView.context).toFloat() + override fun getMultiItemStopSize(viewHolder: RecyclerView.ViewHolder, swipeDirection: SwipeDirection): Float { + if (viewHolder !is RowSwipeable) { + throw IllegalStateException("SwipeToArchiveCallback::getMultiItemStopSize: ViewHolder must implement RowSwipeable") + } + + val hasTwoButtons = when (swipeDirection) { + SwipeDirection.Left -> viewHolder.swipeButtonLayout.rightSecondary() != null + SwipeDirection.Right -> viewHolder.swipeButtonLayout.leftSecondary() != null + } + + return if (hasTwoButtons) { + maxButtonWidth + } else { + // only one button on the right + maxButtonWidth / 2 + }.dpToPx(viewHolder.itemView.context).toFloat() } override fun getMultiItemCutoffThreshold(): Float { @@ -159,13 +215,13 @@ private abstract class SwipeToArchiveCallback() : MultiSwipeHelper.SimpleCallbac } override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float { - return getMultiItemStopSize(viewHolder) - 1f + return maxButtonWidth - 1f } override fun augmentUpdateDxDy(dx: Float, dy: Float): Pair { - val transformedDx = if (swipeDirection ?: 0 < 0) { + val transformedDx = if ((swipeDirection ?: 0) < 0) { min(dx, 0f) - } else if (swipeDirection ?: 0 > 0) { + } else if ((swipeDirection ?: 0) > 0) { max(dx, 0f) } else { dx @@ -179,17 +235,19 @@ private abstract class SwipeToArchiveCallback() : MultiSwipeHelper.SimpleCallbac } override fun onChildDraw(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) { + val episodeViewHolder = viewHolder as? RowSwipeable ?: return - if (actionState == MultiSwipeHelper.ACTION_STATE_IDLE || swipeDirection ?: 0 > 0 && dX <= 0 || swipeDirection ?: 0 < 0 && dX >= 0 || episodeViewHolder.isMultiSelecting) { - return - } + + if (actionState == MultiSwipeHelper.ACTION_STATE_IDLE || + (swipeDirection ?: 0) > 0 && dX <= 0 || + (swipeDirection ?: 0) < 0 && dX >= 0 || + episodeViewHolder.isMultiSelecting + ) return val foregroundView = episodeViewHolder.episodeRow val rightToLeftLayout = episodeViewHolder.rightToLeftSwipeLayout val leftToRightLayout = episodeViewHolder.leftToRightSwipeLayout - val item1Icon = episodeViewHolder.leftIconDrawablesRes[0] - val item2Icon = episodeViewHolder.leftIconDrawablesRes.getOrNull(1) defaultDrawWithoutTranslation(recyclerView, foregroundView, isCurrentlyActive) foregroundView.translationX = dX @@ -200,62 +258,123 @@ private abstract class SwipeToArchiveCallback() : MultiSwipeHelper.SimpleCallbac leftToRightLayout.isVisible = true if (foregroundView.translationX < 0) { - // Is swiping from the right - leftToRightLayout.translationX = foregroundView.translationX + handleSwipeFromRight( + rightToLeftLayout = rightToLeftLayout, + foregroundView = foregroundView, + leftToRightLayout = leftToRightLayout, + button1 = episodeViewHolder.swipeButtonLayout.rightPrimary(), + button2 = episodeViewHolder.swipeButtonLayout.rightSecondary(), + episodeViewHolder = episodeViewHolder + ) + } else { + handleSwipeFromLeft( + rightToLeftLayout = rightToLeftLayout, + foregroundView = foregroundView, + leftToRightLayout = leftToRightLayout, + button1 = episodeViewHolder.swipeButtonLayout.leftPrimary(), + button2 = episodeViewHolder.swipeButtonLayout.leftSecondary(), + episodeViewHolder = episodeViewHolder + ) + } + } - val iconView = episodeViewHolder.swipeLeftIcon - val rightIcon = episodeViewHolder.rightIconDrawableRes.first() - iconView.setImageResource(rightIcon.iconRes) - rightToLeftLayout.setBackgroundColor(rightIcon.backgroundColor) + private fun handleSwipeFromRight( + rightToLeftLayout: ViewGroup, + foregroundView: ViewGroup, + leftToRightLayout: ViewGroup, + button1: SwipeButton, + button2: SwipeButton?, + episodeViewHolder: RowSwipeable, + ) { + leftToRightLayout.translationX = foregroundView.translationX - if (abs(foregroundView.translationX) / foregroundView.width < getMultiItemCutoffThreshold()) { - iconView.x = max(rightToLeftLayout.width + foregroundView.translationX, rightToLeftLayout.width - iconView.width.toFloat()) - } else { - iconView.x = rightToLeftLayout.width + foregroundView.translationX - } + val item1 = rightToLeftLayout.findViewById(UR.id.rightLeftItem1) + val item2 = rightToLeftLayout.findViewById(UR.id.rightLeftItem2) + + val context = rightToLeftLayout.context + + item1.setBackgroundColor(button1.backgroundColor(context)) + if (button2 != null) { + item2.setBackgroundColor(button2.backgroundColor(context)) + } + + episodeViewHolder.rightLeftIcon1.setImageResource(button1.iconRes) + if (button2 != null) { + episodeViewHolder.rightLeftIcon2.setImageResource(button2.iconRes) } else { - // Is swiping from the left - rightToLeftLayout.translationX = foregroundView.translationX + episodeViewHolder.rightLeftIcon2.setImageDrawable(null) + } - val item1 = leftToRightLayout.findViewById(UR.id.leftRightItem1) - val item2 = leftToRightLayout.findViewById(UR.id.leftRightItem2) + if (abs(foregroundView.translationX) / foregroundView.width <= getMultiItemCutoffThreshold() && + button2 != null + ) { + episodeViewHolder.leftRightIcon1.setImageResource(button1.iconRes) - item1.setBackgroundColor(item1Icon.backgroundColor) - if (item2Icon != null) { - item2.setBackgroundColor(item2Icon.backgroundColor) + if (item1.x >= 10f && item1.x == item2.x) { + // Transition between modes + item1.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) } - - episodeViewHolder.leftRightIcon1.setImageResource(item1Icon.iconRes) - if (item2Icon != null) { - episodeViewHolder.leftRightIcon2.setImageResource(item2Icon.iconRes) - } else { - episodeViewHolder.leftRightIcon2.setImageDrawable(null) + item1.x = foregroundView.width - abs(foregroundView.translationX) / 2 + item2.x = foregroundView.width - abs(foregroundView.translationX) + } else { + if (item1.x >= 10f && item1.x < item2.x) { + // Transition between modes + item1.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) } + item1.x = foregroundView.width - abs(foregroundView.translationX) // - item1.width + item2.x = foregroundView.width - abs(foregroundView.translationX) // - item2.width + } + } - if (foregroundView.translationX / foregroundView.width <= getMultiItemCutoffThreshold() && item2Icon != null) { - item1.setBackgroundColor(item1Icon.backgroundColor) - episodeViewHolder.leftRightIcon1.setImageResource(item1Icon.iconRes) + private fun handleSwipeFromLeft( + rightToLeftLayout: ViewGroup, + foregroundView: ViewGroup, + leftToRightLayout: ViewGroup, + button1: SwipeButton, + button2: SwipeButton?, + episodeViewHolder: RowSwipeable, + ) { + rightToLeftLayout.translationX = foregroundView.translationX - if (item1.x >= 10f && item1.x == item2.x) { - // Transition between modes - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - item1.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) - } - } - item1.x = foregroundView.translationX / 2 - item1.width - item2.x = foregroundView.translationX - item2.width - } else { - item1.setBackgroundColor(item1Icon.backgroundColor) + val item1 = leftToRightLayout.findViewById(UR.id.leftRightItem1) + val item2 = leftToRightLayout.findViewById(UR.id.leftRightItem2) - if (item1.x >= 10f && item1.x < item2.x) { - // Transition between modes - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - item1.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) - } - } - item1.x = foregroundView.translationX - item1.width - item2.x = foregroundView.translationX - item2.width + val context = leftToRightLayout.context + + item1.setBackgroundColor(button1.backgroundColor(context)) + if (button2 != null) { + item2.setBackgroundColor(button2.backgroundColor(context)) + } + + episodeViewHolder.leftRightIcon1.setImageResource(button1.iconRes) + if (button2 != null) { + episodeViewHolder.leftRightIcon2.setImageResource(button2.iconRes) + } else { + episodeViewHolder.leftRightIcon2.setImageDrawable(null) + } + + if (foregroundView.translationX / foregroundView.width <= getMultiItemCutoffThreshold() && + button2 != null + ) { + item1.setBackgroundColor(button1.backgroundColor(context)) + episodeViewHolder.leftRightIcon1.setImageResource(button1.iconRes) + + if (item1.x >= 10f && item1.x == item2.x) { + // Transition between modes + item1.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + } + + item1.x = foregroundView.translationX / 2 - item1.width + item2.x = foregroundView.translationX - item2.width + } else { + item1.setBackgroundColor(button1.backgroundColor(context)) + + if (item1.x >= 10f && item1.x < item2.x) { + // Transition between modes + item1.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) } + item1.x = foregroundView.translationX - item1.width + item2.x = foregroundView.translationX - item2.width } } diff --git a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectBottomSheet.kt b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectBottomSheet.kt index 082573c7714..886c8a790fe 100644 --- a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectBottomSheet.kt +++ b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectBottomSheet.kt @@ -85,7 +85,7 @@ class MultiSelectBottomSheet : BaseDialogFragment() { } private fun onClick(item: MultiSelectAction) { - multiSelectHelper?.onMenuItemSelected(item.actionId, resources, childFragmentManager) + multiSelectHelper?.onMenuItemSelected(item.actionId, resources, parentFragmentManager) dismiss() } diff --git a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectEpisodeAction.kt b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectEpisodeAction.kt index e9140ab3573..b674aceb2e9 100644 --- a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectEpisodeAction.kt +++ b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectEpisodeAction.kt @@ -60,6 +60,13 @@ sealed class MultiSelectEpisodeAction( R.drawable.ic_delete, "delete" ) + object Share : MultiSelectEpisodeAction( + groupId = R.id.menu_share, + actionId = R.id.menu_share, + title = LR.string.share, + iconRes = IR.drawable.ic_share, + analyticsValue = "share", + ) object MarkAsUnplayed : MultiSelectEpisodeAction( R.id.menu_mark_played, UR.id.menu_markasunplayed, @@ -104,7 +111,7 @@ sealed class MultiSelectEpisodeAction( ) companion object { - val STANDARD = listOf(Download, Archive, MarkAsPlayed, PlayNext, PlayLast, Star) + val STANDARD = listOf(Download, Archive, MarkAsPlayed, PlayNext, PlayLast, Star, Share) val ALL = STANDARD + listOf(DeleteDownload, DeleteUserEpisode, MarkAsUnplayed, Unstar, Unarchive) val STANDARD_BY_ID = STANDARD.associateBy { it.actionId } val ALL_BY_ID = ALL.associateBy { it.actionId } @@ -156,6 +163,11 @@ sealed class MultiSelectEpisodeAction( } R.id.menu_playnext -> return PlayNext R.id.menu_playlast -> return PlayLast + R.id.menu_share -> { + if (selected.size == 1 && + selected.firstOrNull() is PodcastEpisode + ) return Share + } } return null diff --git a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectEpisodesHelper.kt b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectEpisodesHelper.kt index 0ec355724da..b7c092e25d9 100644 --- a/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectEpisodesHelper.kt +++ b/modules/services/views/src/main/java/au/com/shiftyjelly/pocketcasts/views/multiselect/MultiSelectEpisodesHelper.kt @@ -1,12 +1,15 @@ package au.com.shiftyjelly.pocketcasts.views.multiselect import android.content.res.Resources +import android.widget.Toast import androidx.fragment.app.FragmentManager import androidx.lifecycle.LiveData import androidx.lifecycle.map import androidx.lifecycle.toLiveData import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent +import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTrackerWrapper import au.com.shiftyjelly.pocketcasts.analytics.EpisodeAnalytics +import au.com.shiftyjelly.pocketcasts.analytics.SourceView import au.com.shiftyjelly.pocketcasts.localization.extensions.getStringPlural import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode @@ -18,8 +21,10 @@ import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.UserEpisodeManager import au.com.shiftyjelly.pocketcasts.utils.combineLatest +import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer import au.com.shiftyjelly.pocketcasts.views.R import au.com.shiftyjelly.pocketcasts.views.dialog.ConfirmationDialog +import au.com.shiftyjelly.pocketcasts.views.dialog.ShareDialog import au.com.shiftyjelly.pocketcasts.views.helper.CloudDeleteHelper import au.com.shiftyjelly.pocketcasts.views.helper.DeleteState import io.reactivex.BackpressureStrategy @@ -42,6 +47,7 @@ class MultiSelectEpisodesHelper @Inject constructor( val podcastManager: PodcastManager, val playbackManager: PlaybackManager, val downloadManager: DownloadManager, + val analyticsTracker: AnalyticsTrackerWrapper, val settings: Settings, private val episodeAnalytics: EpisodeAnalytics ) : MultiSelectHelper() { @@ -77,6 +83,10 @@ class MultiSelectEpisodesHelper @Inject constructor( delete(resources, fragmentManager) true } + R.id.menu_share -> { + share(fragmentManager) + true + } R.id.menu_download -> { download(resources, fragmentManager) true @@ -381,6 +391,42 @@ class MultiSelectEpisodesHelper @Inject constructor( CloudDeleteHelper.getDeleteDialog(episodes, deleteState, deleteFunction, resources).show(fragmentManager, "delete_warning") } + fun share(fragmentManager: FragmentManager) { + + val episode = selectedList.let { list -> + if (list.size != 1) { + LogBuffer.e(LogBuffer.TAG_INVALID_STATE, "Can only share one episode, but trying to share ${selectedList.size} episodes when multi selecting") + return + } else { + list.first() + } + } + + if (episode !is PodcastEpisode) { + LogBuffer.e(LogBuffer.TAG_INVALID_STATE, "Can only share a ${PodcastEpisode::class.java.simpleName}") + Toast.makeText(context, LR.string.podcasts_share_failed, Toast.LENGTH_SHORT).show() + return + } + + launch { + val podcast = podcastManager.findPodcastByUuidSuspend(episode.podcastUuid) ?: run { + LogBuffer.e(LogBuffer.TAG_INVALID_STATE, "Share failed because unable to find podcast from uuid") + return@launch + } + + ShareDialog( + episode = episode, + podcast = podcast, + fragmentManager = fragmentManager, + context = context, + shouldShowPodcast = false, + analyticsTracker = analyticsTracker, + ).show(sourceView = SourceView.MULTI_SELECT) + } + + closeMultiSelect() + } + fun removeFromUpNext(resources: Resources) { val list = selectedList.toList() launch { diff --git a/modules/services/views/src/main/res/menu/menu_multiselect.xml b/modules/services/views/src/main/res/menu/menu_multiselect.xml index 55cb45fd46b..6c2c472f170 100644 --- a/modules/services/views/src/main/res/menu/menu_multiselect.xml +++ b/modules/services/views/src/main/res/menu/menu_multiselect.xml @@ -13,6 +13,10 @@ android:icon="@drawable/ic_delete" android:title="@string/delete" app:showAsAction="always" /> + - \ No newline at end of file + diff --git a/modules/services/views/src/test/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeButtonLayoutViewModelTest.kt b/modules/services/views/src/test/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeButtonLayoutViewModelTest.kt new file mode 100644 index 00000000000..e5659a714b6 --- /dev/null +++ b/modules/services/views/src/test/java/au/com/shiftyjelly/pocketcasts/views/helper/SwipeButtonLayoutViewModelTest.kt @@ -0,0 +1,298 @@ +package au.com.shiftyjelly.pocketcasts.views.helper + +import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode +import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode +import au.com.shiftyjelly.pocketcasts.models.entity.UserEpisode +import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager +import au.com.shiftyjelly.pocketcasts.repositories.playback.UpNextQueue +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import org.junit.Assert.assertNotEquals +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.mock +import org.mockito.kotlin.whenever + +@RunWith(MockitoJUnitRunner::class) +class SwipeButtonLayoutViewModelTest { + + @Mock private lateinit var playbackManager: PlaybackManager + + @Mock private lateinit var upNextQueue: UpNextQueue + + private val buttons: SwipeButtonLayoutViewModel.SwipeButtons = + SwipeButtonLayoutViewModel.SwipeButtons( + addToUpNextTop = mock(name = "addToUpNextTop"), + addToUpNextBottom = mock(name = "addToUpNextBottom"), + removeFromUpNext = mock(name = "removeFromUpNext"), + archive = mock(name = "archive"), + deleteFile = mock(name = "deleteFile"), + share = mock(name = "share"), + ) + + private lateinit var testSubject: SwipeButtonLayoutViewModel + + @Before + fun setup() { + testSubject = SwipeButtonLayoutViewModel( + analyticsTracker = mock(), + context = mock(), + episodeAnalytics = mock(), + episodeManager = mock(), + playbackManager = playbackManager, + podcastManager = mock(), + userEpisodeManager = mock(), + ) + } + + /* + * Add/Remove from queue + */ + + @Test + fun `show queue buttons in user settings order when not on up next screen when defaulting to top`() { + val topDefault = getSwipeButtonLayout( + onUpNextScreen = false, + defaultUpNextSwipeAction = Settings.UpNextAction.PLAY_NEXT + ) + assertEquals(buttons.addToUpNextTop, topDefault.leftPrimary()) + assertEquals(buttons.addToUpNextBottom, topDefault.leftSecondary()) + } + + @Test + fun `show queue buttons in user setting order when not on up next screen when defaulting to bottom`() { + val bottomDefault = getSwipeButtonLayout( + onUpNextScreen = false, + defaultUpNextSwipeAction = Settings.UpNextAction.PLAY_LAST + ) + assertEquals(buttons.addToUpNextBottom, bottomDefault.leftPrimary()) + assertEquals(buttons.addToUpNextTop, bottomDefault.leftSecondary()) + } + + @Test + fun `ignores user setting for queue actions order when on up next screen`() { + val verifyTopFirst = { layout: SwipeButtonLayout -> + assertEquals(buttons.addToUpNextTop, layout.leftPrimary()) + assertEquals(buttons.addToUpNextBottom, layout.leftSecondary()) + } + + val defaultNext = getSwipeButtonLayout( + onUpNextScreen = true, + defaultUpNextSwipeAction = Settings.UpNextAction.PLAY_NEXT + ) + verifyTopFirst(defaultNext) + + val defaultLast = getSwipeButtonLayout( + onUpNextScreen = true, + defaultUpNextSwipeAction = Settings.UpNextAction.PLAY_LAST + ) + verifyTopFirst(defaultLast) + } + + @Test + fun `if episode is queued, shows remove queue button on left when not on Up Next screen`() { + val verifyRemoveButton = { layout: SwipeButtonLayout -> + assertEquals(buttons.removeFromUpNext, layout.leftPrimary()) + assertNull(layout.leftSecondary()) + } + + val withPodcastEpisode = getSwipeButtonLayout( + onUpNextScreen = false, + episode = mock(), + episodeInUpNext = true + ) + verifyRemoveButton(withPodcastEpisode) + + val withUserEpisode = getSwipeButtonLayout( + onUpNextScreen = false, + episode = mock(), + episodeInUpNext = true + ) + verifyRemoveButton(withUserEpisode) + } + + @Test + fun `shows remove from queue button always on right on Up Next screen`() { + val verifyRemoveButton = { layout: SwipeButtonLayout -> + // remove button is right primary... + assertEquals(buttons.removeFromUpNext, layout.rightPrimary()) + + // ...and nowhere else + assertNull(layout.rightSecondary()) + assertNotEquals(buttons.removeFromUpNext, layout.leftPrimary()) + assertNotEquals(buttons.removeFromUpNext, layout.leftSecondary()) + } + + val withPodcastEpisode = getSwipeButtonLayout( + episode = mock(), + onUpNextScreen = true, + episodeInUpNext = true + ) + verifyRemoveButton(withPodcastEpisode) + + val withUserEpisode = getSwipeButtonLayout( + episode = mock(), + onUpNextScreen = true, + episodeInUpNext = true + ) + verifyRemoveButton(withUserEpisode) + } + + /* + * Delete / archive buttons + */ + + @Test + fun `shows archive button for podcast episodes when not on Up Next screen`() { + val verifyArchiveButton = { layout: SwipeButtonLayout -> + // archive button is right primary... + assertEquals(buttons.archive, layout.rightPrimary()) + + // ...and nowhere else + assertNotEquals(buttons.archive, layout.rightSecondary()) + assertNotEquals(buttons.archive, layout.leftPrimary()) + assertNotEquals(buttons.archive, layout.leftSecondary()) + + // ...and there's no deleteFile button + assertNotEquals(buttons.deleteFile, layout.rightSecondary()) + assertNotEquals(buttons.deleteFile, layout.leftPrimary()) + assertNotEquals(buttons.deleteFile, layout.leftSecondary()) + } + + val default = getSwipeButtonLayout( + episode = mock(), + ) + verifyArchiveButton(default) + + val inUpNextQueue = getSwipeButtonLayout( + episode = mock(), + episodeInUpNext = true + ) + verifyArchiveButton(inUpNextQueue) + } + + @Test + fun `shows archive button for user episodes when not on Up Next Screen`() { + val verifyDeleteFileButton = { layout: SwipeButtonLayout -> + // deleteFile button is right primary... + assertEquals(buttons.deleteFile, layout.rightPrimary()) + + // ...and nowhere else + assertNotEquals(buttons.deleteFile, layout.rightSecondary()) + assertNotEquals(buttons.deleteFile, layout.leftPrimary()) + assertNotEquals(buttons.deleteFile, layout.leftSecondary()) + + // ...and there's no archive button + assertNotEquals(buttons.archive, layout.rightSecondary()) + assertNotEquals(buttons.archive, layout.leftPrimary()) + assertNotEquals(buttons.archive, layout.leftSecondary()) + } + + val default = getSwipeButtonLayout( + episode = mock(), + ) + verifyDeleteFileButton(default) + + val inUpNextQueue = getSwipeButtonLayout( + episode = mock(), + episodeInUpNext = true + ) + verifyDeleteFileButton(inUpNextQueue) + } + + /* + * Share Button + */ + + @Test + fun `shows share button when not on Up Next Screen`() { + val withShare = getSwipeButtonLayout( + onUpNextScreen = false, + showShareButton = true + ) + assertEquals(buttons.share, withShare.rightSecondary()) + } + + @Test + fun `does not show share button if showShareButton is false`() { + val shareSetToFalse = getSwipeButtonLayout( + onUpNextScreen = false, + showShareButton = false + ) + verifyNoShareButton(shareSetToFalse) + } + + @Test + fun `does not show share button for User Episodes`() { + val withUserEpisode = getSwipeButtonLayout( + episode = mock(), + onUpNextScreen = false, + showShareButton = false + ) + verifyNoShareButton(withUserEpisode) + } + + @Test + fun `does not show share button on Up Next screen, even if showShareButton is true`() { + val layout = getSwipeButtonLayout( + episode = mock(), + onUpNextScreen = true, + showShareButton = true + ) + verifyNoShareButton(layout) + } + + private fun verifyNoShareButton(layout: SwipeButtonLayout) { + assertNotEquals(buttons.share, layout.leftPrimary()) + assertNotEquals(buttons.share, layout.leftSecondary()) + assertNotEquals(buttons.share, layout.rightPrimary()) + assertNotEquals(buttons.share, layout.rightSecondary()) + } + + /* + * Helpers + */ + + private fun getSwipeButtonLayout( + episode: BaseEpisode = mock(), + episodeInUpNext: Boolean = false, + showShareButton: Boolean = true, + onUpNextScreen: Boolean = false, + defaultUpNextSwipeAction: Settings.UpNextAction = Settings.UpNextAction.PLAY_NEXT, + ): SwipeButtonLayout { + + if (!onUpNextScreen) { + // Only stub these when we're not on the up next screen. Otherwise mockito gets upset about + // unnecessary stubbings + whenever(playbackManager.upNextQueue).thenReturn(upNextQueue) + whenever(upNextQueue.contains(episode.uuid)).thenReturn(episodeInUpNext) + } + + val swipeSource = if (onUpNextScreen) { + EpisodeItemTouchHelper.SwipeSource.UP_NEXT + } else { + // All of these are treated the same, so picking one at random + // to make sure they're all (kind of) covered. Better would be + // to convert this to a parameterized test at some point. + listOf( + EpisodeItemTouchHelper.SwipeSource.PODCAST_DETAILS, + EpisodeItemTouchHelper.SwipeSource.FILES, + EpisodeItemTouchHelper.SwipeSource.FILTERS, + EpisodeItemTouchHelper.SwipeSource.DOWNLOADS, + EpisodeItemTouchHelper.SwipeSource.LISTENING_HISTORY, + EpisodeItemTouchHelper.SwipeSource.STARRED, + ).random() + } + return testSubject.getSwipeButtonLayout( + episode = episode, + swipeSource = swipeSource, + showShareButton = showShareButton, + defaultUpNextSwipeAction = { defaultUpNextSwipeAction }, + buttons = buttons + ) + } +}