Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Podcast Update Feed] Add Tooltip #3561

Merged
merged 9 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,10 @@ class MainActivity :
return binding.snackbarFragment
}

override fun setFullScreenDarkOverlayViewVisibility(visible: Boolean) {
binding.fullScreenDarkOverlayView.isVisible = visible
}

override fun onMiniPlayerHidden() {
updateSnackbarPosition(miniPlayerOpen = false)
settings.updateBottomInset(0)
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />

<View
android:id="@+id/fullScreenDarkOverlayView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#80000000"
android:visibility="gone" />

<au.com.shiftyjelly.pocketcasts.views.component.RadioactiveLineView
android:id="@+id/radioactiveLineView"
android:layout_width="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ class AutomotiveSettingsActivity : AppCompatActivity(), FragmentHostListener {
return findViewById(android.R.id.content)
}

override fun setFullScreenDarkOverlayViewVisibility(visible: Boolean) {
}

override fun showAccountUpgradeNow(autoSelectPlus: Boolean) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,10 +446,10 @@ class PodcastAdapter(
}
}

fun setPodcast(podcast: Podcast) {
fun setPodcast(podcast: Podcast, forceHeaderExpanded: Boolean? = null) {
// expand the podcast description and details if the user hasn't subscribed
if (this.podcast.uuid != podcast.uuid) {
headerExpanded = !podcast.isSubscribed
headerExpanded = forceHeaderExpanded ?: !podcast.isSubscribed
ratingsViewModel.loadRatings(podcast.uuid)
ratingsViewModel.refreshPodcastRatings(podcast.uuid)
onHeaderSummaryToggled(headerExpanded, false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.core.view.isGone
Expand All @@ -23,6 +31,7 @@ import androidx.recyclerview.widget.SimpleItemAnimator
import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent
import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTracker
import au.com.shiftyjelly.pocketcasts.analytics.SourceView
import au.com.shiftyjelly.pocketcasts.compose.AppTheme
import au.com.shiftyjelly.pocketcasts.localization.extensions.getStringPlural
import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode
import au.com.shiftyjelly.pocketcasts.models.entity.Bookmark
Expand Down Expand Up @@ -90,6 +99,7 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx2.asObservable
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -168,6 +178,9 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener {

private var listState: Parcelable? = null

private var tooltipOffset by mutableStateOf(IntOffset.Zero)
private var canShowTooltip by mutableStateOf(false)

private var currentSnackBar: Snackbar? = null

private val onScrollListener = object : RecyclerView.OnScrollListener() {
Expand Down Expand Up @@ -735,6 +748,39 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener {
super.onViewCreated(view, savedInstanceState)
binding?.setupMultiSelect()

binding?.composeTooltipHost?.setContent {
AppTheme(theme.activeTheme) {
val shouldShowPodcastTooltip by viewModel.shouldShowPodcastTooltip.collectAsState()

var show by remember { mutableStateOf(true) }

LaunchedEffect(canShowTooltip, shouldShowPodcastTooltip) {
show = canShowTooltip && shouldShowPodcastTooltip && FeatureFlag.isEnabled(Feature.PODCAST_FEED_UPDATE)
}

if (show) {
PodcastTooltip(
title = stringResource(LR.string.podcast_feed_update_tooltip_title),
subtitle = stringResource(LR.string.podcast_feed_update_tooltip_subtitle),
offset = tooltipOffset,
onTooltipShown = {
(activity as? FragmentHostListener)?.setFullScreenDarkOverlayViewVisibility(true)
analyticsTracker.track(AnalyticsEvent.PODCAST_REFRESH_EPISODE_TOOLTIP_SHOWN)
},
onDismissRequest = {
hideTooltip()
},
onCloseButtonClick = {
analyticsTracker.track(AnalyticsEvent.PODCAST_REFRESH_EPISODE_TOOLTIP_DISMISSED)
hideTooltip()
},
)
} else {
(activity as? FragmentHostListener)?.setFullScreenDarkOverlayViewVisibility(false)
}
}
}

viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.multiSelectBookmarksHelper.navigationState
Expand All @@ -748,6 +794,46 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener {
}
}

private fun configureTooltip() {
lifecycleScope.launch {
delay(1.seconds) // Delay to wait the recyclerview to be configured
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this delay because there is a race condition. Since we are aligning the tooltip with one recyclerview's item, I need to make sure this component has loaded. Using episodesRecyclerView?.doOnNextLayout was not enough

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this delay works if the page takes a while to load.

delay.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fixed now. I had to mock low internet connection to be able to see this issue. See how it is now:

Screen_recording_20250207_123341.webm


val headerPositionInList = 2 // See: au.com.shiftyjelly.pocketcasts.podcasts.view.podcast.PodcastAdapter.setEpisodes

val viewHolder = binding?.episodesRecyclerView?.findViewHolderForAdapterPosition(headerPositionInList)
as? PodcastAdapter.EpisodeHeaderViewHolder

val anchorView = viewHolder?.binding?.btnEpisodeOptions
anchorView?.let { showTooltipAbove(it) }
}
}

private fun showTooltipAbove(view: View) {
val anchorLocation = IntArray(2)
view.getLocationOnScreen(anchorLocation)

val composeLocation = IntArray(2)
val tooltipComposeView = binding?.composeTooltipHost ?: return

tooltipComposeView.getLocationOnScreen(composeLocation)

val anchorX = anchorLocation[0] - composeLocation[0] + (view.width / 2)
var anchorY = anchorLocation[1] - composeLocation[1] - 360

if (anchorY < 0) {
anchorY = 0
}

tooltipOffset = IntOffset(anchorX, anchorY)
canShowTooltip = true
}

private fun hideTooltip() {
(activity as? FragmentHostListener)?.setFullScreenDarkOverlayViewVisibility(false)
viewModel.hidePodcastRefreshTooltip()
canShowTooltip = false
}

private fun onShareBookmarkClick() {
lifecycleScope.launch {
val (podcast, episode, bookmark) = viewModel.getSharedBookmark() ?: return@launch
Expand Down Expand Up @@ -865,7 +951,8 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener {
binding?.toolbar?.setBackgroundColor(backgroundColor)
binding?.headerBackgroundPlaceholder?.setBackgroundColor(backgroundColor)

adapter?.setPodcast(podcast)
val forceHeaderExpanded = !viewModel.shouldShowPodcastTooltip.value && FeatureFlag.isEnabled(Feature.PODCAST_FEED_UPDATE)
adapter?.setPodcast(podcast, forceHeaderExpanded = forceHeaderExpanded)

viewModel.archiveEpisodeLimit()

Expand Down Expand Up @@ -901,6 +988,7 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener {
podcast = state.podcast,
context = requireContext(),
)
configureTooltip()
}
PodcastTab.BOOKMARKS -> {
adapter?.setBookmarks(
Expand Down Expand Up @@ -1003,6 +1091,7 @@ class PodcastFragment : BaseFragment(), Toolbar.OnMenuItemClickListener {
binding = null
currentSnackBar?.dismiss()
currentSnackBar = null
(activity as? FragmentHostListener)?.setFullScreenDarkOverlayViewVisibility(false)
}

private fun archiveAllPlayed() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package au.com.shiftyjelly.pocketcasts.podcasts.view.podcast

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import au.com.shiftyjelly.pocketcasts.compose.CallOnce
import au.com.shiftyjelly.pocketcasts.compose.components.TextH30
import au.com.shiftyjelly.pocketcasts.compose.components.TextP40
import au.com.shiftyjelly.pocketcasts.compose.theme
import au.com.shiftyjelly.pocketcasts.localization.R as LR

@Composable
fun PodcastTooltip(
title: String,
subtitle: String,
offset: IntOffset,
onTooltipShown: () -> Unit,
onDismissRequest: () -> Unit,
onCloseButtonClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val tooltipColor = MaterialTheme.theme.colors.primaryUi01

CallOnce {
onTooltipShown.invoke()
}

Popup(
alignment = Alignment.TopStart,
offset = offset,
onDismissRequest = onDismissRequest,
) {
Box(
modifier = modifier.background(Color.Transparent).padding(16.dp).widthIn(max = 400.dp),
) {
TooltipContent(tooltipColor, title, subtitle, onCloseButtonClick)

TooltipArrow(tooltipColor)
}
}
}

@Composable
private fun TooltipContent(
tooltipColor: Color,
title: String,
subtitle: String,
onCloseButtonClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
backgroundColor = tooltipColor,
shape = RoundedCornerShape(8.dp),
elevation = 0.dp,
modifier = modifier,
) {
Row(
modifier = Modifier.background(tooltipColor).padding(16.dp),
verticalAlignment = Alignment.Top,
) {
Column(
modifier = Modifier.weight(1f),
) {
TextH30(
text = title,
modifier = Modifier.padding(bottom = 4.dp),
)
mebarbosa marked this conversation as resolved.
Show resolved Hide resolved

TextP40(
text = subtitle,
fontSize = 12.sp,
color = MaterialTheme.theme.colors.primaryText02,
)
}

Spacer(modifier = Modifier.width(12.dp))

Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(LR.string.close),
tint = MaterialTheme.theme.colors.primaryIcon02,
modifier = Modifier
.align(Alignment.Top)
.width(24.dp)
.clickable {
onCloseButtonClick.invoke()
},
)
}
}
}

@Composable
private fun BoxScope.TooltipArrow(tooltipColor: Color) {
Canvas(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 28.dp),
) {
val triangleSize = 12.dp.toPx()

drawPath(
path = Path().apply {
moveTo(0f, -2f)
lineTo(triangleSize, 0f)
lineTo(triangleSize / 2f, triangleSize)
close()
},
color = tooltipColor,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -102,6 +103,8 @@ class PodcastViewModel
private val _refreshState = MutableSharedFlow<RefreshState>()
val refreshState = _refreshState.asSharedFlow()

val shouldShowPodcastTooltip = MutableStateFlow(settings.showPodcastRefreshTooltip.value)

val groupedEpisodes: MutableLiveData<List<List<PodcastEpisode>>> = MutableLiveData()
val signInState = userManager.getSignInState().toLiveData()

Expand Down Expand Up @@ -592,6 +595,11 @@ class PodcastViewModel
}
}

fun hidePodcastRefreshTooltip() {
settings.showPodcastRefreshTooltip.set(false, updateModifiedAt = false)
shouldShowPodcastTooltip.value = false
}

private fun trackEpisodeBulkEvent(event: AnalyticsEvent, count: Int) {
episodeAnalytics.trackBulkEvent(
event,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />

<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeTooltipHost"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
/>

</FrameLayout>

</LinearLayout>
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ enum class AnalyticsEvent(val key: String) {
PODCAST_SCREEN_REFRESH_EPISODE_LIST("podcast_screen_refresh_episode_list"),
PODCAST_SCREEN_REFRESH_NEW_EPISODE_FOUND("podcast_screen_refresh_new_episode_found"),
PODCAST_SCREEN_REFRESH_NO_EPISODES_FOUND("podcast_screen_refresh_no_episodes_found"),
PODCAST_REFRESH_EPISODE_TOOLTIP_SHOWN("podcast_refresh_episode_tooltip_shown"),
PODCAST_REFRESH_EPISODE_TOOLTIP_DISMISSED("podcast_refresh_episode_tooltip_dismissed"),

/* Podcast Settings */
PODCAST_SETTINGS_FEED_ERROR_TAPPED("podcast_settings_feed_error_tapped"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,8 @@
<string name="podcast_group_unplayed" translatable="false">@string/unplayed</string>
<string name="podcast_hide_archived">Hide archived</string>
<string name="podcast_load_error">There was an error loading the podcast.</string>
<string name="podcast_feed_update_tooltip_title">Fresh episodes, coming right up!</string>
<string name="podcast_feed_update_tooltip_subtitle">Pull down or use this menu to see if there\'s something new.</string>
<string name="podcast_loading">Loading your podcasts</string>
<string name="podcast_next_episode_any_day_now">Next episode any day now</string>
<string name="podcast_next_episode_today">Next episode today</string>
Expand Down
Loading