Repository: Automattic/pocket-casts-android
Pull request #3797: Up Next History - Show episode details and restore
Author: login: ashiagr
Labels: name: [Type] Feature
description: Adding a new feature.
name: HACK week
description: Developed as a part of HACK week
name: [Area] Up Next
name: [Area] Settings
Created: 2025-03-20T06:27:35Z | Merged: 2025-03-24T11:38:39Z

## Description
This displays episodes within "Up Next" history and allows restoring them into the "Up Next" queue.

## Testing Instructions

#### Test.1 Restore "Up Next"

1. Repeat steps from `Test.1` in https://github.com/Automattic/pocket-casts-android/pull/3796 to create a snapshot of "Up Next" queue
2. Tap a snapshot entry from `Profile` -> `Settings` -> `Restore from local history` -> `Up Next History`
3. ✅ Notice that episodes are shown for that snapshot
4. Tap "Restore" in the toolbar and confirm
5. ✅ Notice that episodes are added to the end of "Up Next" queue

#### Test.2 Delete entries older than 14 days

1. Change below line in `UpNextHistoryManagerImpl`
`private val periodOfSnapshot: Duration = Duration.ofDays(14)`
to 
`private val periodOfSnapshot: Duration = Duration.ofMinutes(1)`
2. Fresh install the app 
4. Add episodes to queue
5. Clear queue
6. Notice that snapshot entry is present in `Profile` -> `Settings` -> `Restore from local history` -> `Up Next History`
7. Wait for a minute
8. Repeat steps 4-5
9. ✅ Notice that a new snapshot is created and previous snapshot entry is deleted in `Profile` -> `Settings` -> `Restore from local history` -> `Up Next History`


## Screenshots or Screencast 

https://github.com/user-attachments/assets/0312bd4d-8f88-4ccd-867a-6da1844e0a53



## Checklist
- [x] If this is a user-facing change, I have added an entry in CHANGELOG.md
- [x] Ensure the linter passes (`./gradlew spotlessApply` to automatically apply formatting/linting)
- [x] I have considered whether it makes sense to add tests for my changes
- [x] All strings that need to be localized are in `modules/services/localization/src/main/res/values/strings.xml`
- [x] Any jetpack compose components I added or changed are covered by compose previews
- [ ] I have updated (or requested that someone edit) [the spreadsheet](https://docs.google.com/spreadsheets/d/107jqrutZhU0fVZJ19SBqxxVKbV2NWSdQC9MFYdLiAxc/edit?usp=sharing) to reflect any new or changed analytics.
 
#### I have tested any UI changes...
<!-- If this PR does not contain UI changes, ignore these items -->
- [x] with different themes
- [x] with a landscape orientation
- [x] with the device set to have a large display and font size
- [ ] for accessibility with TalkBack



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e269520b33c..0588db6eb78 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@
 *   Updates
     *   Show a confirmation dialog when deleting more than three episode downloads.
         ([#3800](https://github.com/Automattic/pocket-casts-android/pull/3800))
+    *   Add settings option to restore up next from local history.
+        ([#3797](https://github.com/Automattic/pocket-casts-android/pull/3797))
         
 7.85
 -----
diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/HistoryFragment.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/HistoryFragment.kt
index f74a67938c1..32c08c3609c 100644
--- a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/HistoryFragment.kt
+++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/HistoryFragment.kt
@@ -9,18 +9,26 @@ import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.navigation.NavHostController
+import androidx.navigation.NavType
 import androidx.navigation.compose.NavHost
 import androidx.navigation.compose.composable
 import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
 import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground
 import au.com.shiftyjelly.pocketcasts.compose.extensions.contentWithoutConsumedInsets
 import au.com.shiftyjelly.pocketcasts.preferences.Settings
+import au.com.shiftyjelly.pocketcasts.settings.history.upnext.UpNextHistoryDetailsPage
+import au.com.shiftyjelly.pocketcasts.settings.history.upnext.UpNextHistoryDetailsViewModel.UiState
 import au.com.shiftyjelly.pocketcasts.settings.history.upnext.UpNextHistoryPage
 import au.com.shiftyjelly.pocketcasts.utils.extensions.pxToDp
+import au.com.shiftyjelly.pocketcasts.views.dialog.ConfirmationDialog
 import au.com.shiftyjelly.pocketcasts.views.fragments.BaseFragment
 import au.com.shiftyjelly.pocketcasts.views.helper.HasBackstack
 import dagger.hilt.android.AndroidEntryPoint
 import javax.inject.Inject
+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
 
 @AndroidEntryPoint
 class HistoryFragment : BaseFragment(), HasBackstack {
@@ -59,15 +67,63 @@ class HistoryFragment : BaseFragment(), HasBackstack {
                 composable(HistoryNavRoutes.UpNextHistory) {
                     UpNextHistoryPage(
                         onHistoryEntryClick = { date ->
+                            navController.navigate(
+                                HistoryNavRoutes.upNextHistoryDetailsDestination(
+                                    date.time,
+                                ),
+                            )
                         },
                         onBackClick = navController::popBackStack,
                         bottomInset = bottomInsetDp,
                     )
                 }
+                composable(
+                    HistoryNavRoutes.upNextHistoryDetailsRoute(),
+                    listOf(
+                        navArgument(HistoryNavRoutes.UpNextHistoryDateArgument) {
+                            type = NavType.LongType
+                        },
+                    ),
+                ) { backStackEntry ->
+                    val arguments =
+                        requireNotNull(backStackEntry.arguments) { "Missing back stack entry arguments" }
+                    val date =
+                        requireNotNull(arguments.getLong(HistoryNavRoutes.UpNextHistoryDateArgument)) {
+                            "Missing date argument"
+                        }
+                    UpNextHistoryDetailsPage(
+                        date = date,
+                        onRestoreClick = ::onRestoreClick,
+                        onBackClick = navController::popBackStack,
+                        bottomInset = bottomInsetDp,
+                    )
+                }
             }
         }
     }
 
+    private fun onRestoreClick(state: UiState, restoreUpNext: () -> Unit) {
+        if (state !is UiState.Loaded) return
+        if (state.isUpNextQueueEmpty) {
+            restoreUpNext()
+            return
+        }
+        showUpNextRestoreConfirmationDialog(restoreUpNext)
+    }
+
+    private fun showUpNextRestoreConfirmationDialog(onRestoreConfirm: () -> Unit) {
+        ConfirmationDialog()
+            .setButtonType(ConfirmationDialog.ButtonType.Normal(getString(LR.string.up_next_history_restore_button)))
+            .setTitle(getString(LR.string.up_next_history_restore_confirmation_title))
+            .setSummary(getString(LR.string.up_next_history_restore_confirmation_description))
+            .setOnConfirm {
+                onRestoreConfirm()
+            }
+            .setIconId(IR.drawable.ic_history)
+            .setIconTint(UR.attr.primary_interactive_01)
+            .show(childFragmentManager, "up-next-restore-confirmation-dialog")
+    }
+
     @Suppress("DEPRECATION")
     private fun onBackClick() {
         activity?.onBackPressed()
@@ -83,5 +139,10 @@ class HistoryFragment : BaseFragment(), HasBackstack {
     object HistoryNavRoutes {
         const val History = "main"
         const val UpNextHistory = "up_next_history"
+        private const val UpNextHistoryDetails = "up_next_history_details"
+
+        const val UpNextHistoryDateArgument = "upNextHistoryDate"
+        fun upNextHistoryDetailsRoute() = "$UpNextHistoryDetails/{$UpNextHistoryDateArgument}"
+        fun upNextHistoryDetailsDestination(date: Long) = "$UpNextHistoryDetails/$date"
     }
 }
diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsPage.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsPage.kt
new file mode 100644
index 00000000000..c18256623df
--- /dev/null
+++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsPage.kt
@@ -0,0 +1,264 @@
+package au.com.shiftyjelly.pocketcasts.settings.history.upnext
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground
+import au.com.shiftyjelly.pocketcasts.compose.Devices
+import au.com.shiftyjelly.pocketcasts.compose.bars.ThemedTopAppBar
+import au.com.shiftyjelly.pocketcasts.compose.components.EpisodeImage
+import au.com.shiftyjelly.pocketcasts.compose.components.HorizontalDivider
+import au.com.shiftyjelly.pocketcasts.compose.components.TextC70
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH50
+import au.com.shiftyjelly.pocketcasts.compose.components.TextP40
+import au.com.shiftyjelly.pocketcasts.compose.components.TextP60
+import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView
+import au.com.shiftyjelly.pocketcasts.compose.preview.ThemePreviewParameterProvider
+import au.com.shiftyjelly.pocketcasts.compose.theme
+import au.com.shiftyjelly.pocketcasts.localization.helper.RelativeDateFormatter
+import au.com.shiftyjelly.pocketcasts.localization.helper.TimeHelper
+import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode
+import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode
+import au.com.shiftyjelly.pocketcasts.repositories.extensions.getSummaryText
+import au.com.shiftyjelly.pocketcasts.settings.history.upnext.UpNextHistoryDetailsViewModel.UiState
+import au.com.shiftyjelly.pocketcasts.ui.theme.Theme
+import java.text.DateFormat
+import java.util.Date
+import au.com.shiftyjelly.pocketcasts.localization.R as LR
+
+@Composable
+fun UpNextHistoryDetailsPage(
+    viewModel: UpNextHistoryDetailsViewModel = hiltViewModel(),
+    date: Long,
+    onRestoreClick: (UiState, restoreUpNext: () -> Unit) -> Unit,
+    onBackClick: () -> Unit,
+    bottomInset: Dp,
+) {
+    val state by viewModel.state.collectAsStateWithLifecycle()
+    UpNextHistoryDetailsView(
+        date = date,
+        state = state,
+        onRestoreClick = { onRestoreClick(state) { viewModel.restoreUpNext() } },
+        onBackClick = onBackClick,
+        bottomInset = bottomInset,
+    )
+}
+
+@Composable
+private fun UpNextHistoryDetailsView(
+    date: Long,
+    state: UiState,
+    onRestoreClick: () -> Unit,
+    onBackClick: () -> Unit,
+    bottomInset: Dp,
+) {
+    Column {
+        ThemedTopAppBar(
+            title = formatDate(date),
+            onNavigationClick = onBackClick,
+            actions = { iconColor ->
+                TextButton(
+                    onClick = onRestoreClick,
+                    enabled = state is UiState.Loaded,
+                ) {
+                    TextH50(text = stringResource(LR.string.restore))
+                }
+            },
+        )
+        when (state) {
+            is UiState.Loading -> LoadingView()
+            is UiState.Loaded -> {
+                UpNextHistoryEpisodes(
+                    state = state,
+                    bottomInset = bottomInset,
+                )
+            }
+
+            is UiState.Error -> UpNextHistoryErrorView()
+        }
+    }
+}
+
+@Composable
+private fun UpNextHistoryEpisodes(
+    state: UiState.Loaded,
+    bottomInset: Dp,
+    modifier: Modifier = Modifier,
+) {
+    LazyColumn(
+        modifier
+            .fillMaxHeight(),
+        contentPadding = PaddingValues(bottom = bottomInset),
+    ) {
+        items(state.episodes) { episode ->
+            EpisodeRow(
+                episode = episode,
+                useEpisodeArtwork = state.useEpisodeArtwork,
+            )
+        }
+    }
+}
+
+@Composable
+private fun UpNextHistoryErrorView() {
+    Box(
+        modifier = Modifier.fillMaxSize(),
+        contentAlignment = Alignment.Center,
+    ) {
+        TextH50(
+            text = stringResource(LR.string.error_generic_message),
+            modifier = Modifier.padding(horizontal = 16.dp),
+        )
+    }
+}
+
+@Composable
+private fun EpisodeRow(
+    episode: BaseEpisode,
+    useEpisodeArtwork: Boolean,
+    modifier: Modifier = Modifier,
+) {
+    val context = LocalContext.current
+    val formattedDuration = remember(episode.durationMs, episode.playedUpToMs, episode.isInProgress) {
+        TimeHelper
+            .getTimeLeft(
+                episode.playedUpToMs,
+                episode.durationMs.toLong(),
+                episode.isInProgress,
+                context,
+            )
+            .text
+    }
+
+    val tintColor = MaterialTheme.theme.colors.primaryText02.toArgb()
+    val summary = remember(episode.durationMs) {
+        val dateFormatter = RelativeDateFormatter(context)
+        episode.getSummaryText(
+            dateFormatter = dateFormatter,
+            tintColor = tintColor,
+            showDuration = false,
+            context = context,
+        )
+    }
+
+    Row(
+        verticalAlignment = Alignment.CenterVertically,
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp, vertical = 12.dp),
+    ) {
+        EpisodeImage(
+            episode = episode,
+            useEpisodeArtwork = useEpisodeArtwork,
+            modifier = modifier.size(56.dp),
+        )
+
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .padding(start = 16.dp),
+        ) {
+            TextC70(
+                text = summary.toString(),
+                maxLines = 1,
+                fontSize = 11.sp,
+                fontWeight = FontWeight.Normal,
+            )
+
+            TextP40(
+                text = episode.title,
+                maxLines = 2,
+            )
+
+            TextP60(
+                text = formattedDuration,
+                maxLines = 1,
+                color = MaterialTheme.theme.colors.primaryText02,
+            )
+        }
+    }
+    HorizontalDivider()
+}
+
+@Composable
+private fun formatDate(date: Long) = remember(date) {
+    val dateFormat = DateFormat.getDateTimeInstance(
+        DateFormat.MEDIUM,
+        DateFormat.SHORT,
+    )
+    dateFormat.format(date)
+}
+
+@Preview(device = Devices.PortraitRegular)
+@Composable
+private fun UpNextHistoryDetailsViewPreview(
+    @PreviewParameter(ThemePreviewParameterProvider::class) themeType: Theme.ThemeType,
+) {
+    AppThemeWithBackground(themeType) {
+        UpNextHistoryDetailsView(
+            date = Date().time,
+            state = UiState.Loaded(
+                episodes = listOf(
+                    PodcastEpisode(
+                        uuid = "1",
+                        title = "Episode 1",
+                        publishedDate = Date(),
+                        duration = 1000.0,
+                    ),
+                    PodcastEpisode(
+                        uuid = "2",
+                        title = "Episode 2",
+                        publishedDate = Date(),
+                        duration = 2000.0,
+                    ),
+                ),
+                useEpisodeArtwork = true,
+            ),
+            onRestoreClick = {},
+            onBackClick = {},
+            bottomInset = 0.dp,
+        )
+    }
+}
+
+@Preview(device = Devices.PortraitRegular)
+@Composable
+private fun UpNextHistoryDetailsErrorViewPreview(
+    @PreviewParameter(ThemePreviewParameterProvider::class) themeType: Theme.ThemeType,
+) {
+    AppThemeWithBackground(themeType) {
+        UpNextHistoryDetailsView(
+            date = Date().time,
+            state = UiState.Error,
+            onRestoreClick = {},
+            onBackClick = {},
+            bottomInset = 0.dp,
+        )
+    }
+}
diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsViewModel.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsViewModel.kt
new file mode 100644
index 00000000000..7037e291b24
--- /dev/null
+++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsViewModel.kt
@@ -0,0 +1,90 @@
+package au.com.shiftyjelly.pocketcasts.settings.history.upnext
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import au.com.shiftyjelly.pocketcasts.analytics.SourceView
+import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode
+import au.com.shiftyjelly.pocketcasts.preferences.Settings
+import au.com.shiftyjelly.pocketcasts.preferences.model.ArtworkConfiguration.Element
+import au.com.shiftyjelly.pocketcasts.repositories.di.IoDispatcher
+import au.com.shiftyjelly.pocketcasts.repositories.history.upnext.UpNextHistoryManager
+import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager
+import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager
+import au.com.shiftyjelly.pocketcasts.settings.history.HistoryFragment.HistoryNavRoutes.UpNextHistoryDateArgument
+import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.util.Date
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+@HiltViewModel
+class UpNextHistoryDetailsViewModel @Inject constructor(
+    private val upNextHistoryManager: UpNextHistoryManager,
+    private val episodeManager: EpisodeManager,
+    private val playbackManager: PlaybackManager,
+    private val settings: Settings,
+    savedStateHandle: SavedStateHandle,
+    @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+) : ViewModel() {
+
+    private val _state = MutableStateFlow<UiState>(UiState.Loading)
+    val state: StateFlow<UiState> = _state.asStateFlow()
+
+    private val date = checkNotNull(savedStateHandle.get<Long>(UpNextHistoryDateArgument))
+
+    init {
+        loadEpisodes()
+    }
+
+    private fun loadEpisodes() {
+        viewModelScope.launch(ioDispatcher) {
+            try {
+                val episodeUuids = upNextHistoryManager.findEpisodeUuidsForDate(Date(date))
+                val episodes = episodeManager.findEpisodesByUuids(episodeUuids)
+                val episodeMap = episodes.associateBy { it.uuid }
+                _state.update {
+                    UiState.Loaded(
+                        episodes = episodeUuids.mapNotNull { episodeUuid -> episodeMap[episodeUuid] },
+                        isUpNextQueueEmpty = playbackManager.upNextQueue.queueEpisodes.isEmpty(),
+                        useEpisodeArtwork = settings.artworkConfiguration.value.useEpisodeArtwork(Element.UpNext),
+                    )
+                }
+            } catch (e: Exception) {
+                val message = "Failed to load episodes for up next history date: $date"
+                Timber.e(e, message)
+                LogBuffer.e(LogBuffer.TAG_BACKGROUND_TASKS, e, message)
+                _state.update { state -> UiState.Error }
+            }
+        }
+    }
+
+    fun restoreUpNext() {
+        viewModelScope.launch(ioDispatcher) {
+            try {
+                val currentState = _state.value as? UiState.Loaded ?: return@launch
+                playbackManager.playEpisodesLast(currentState.episodes, SourceView.UP_NEXT_HISTORY)
+            } catch (e: Exception) {
+                val message = "Failed to restore up next for date: $date"
+                Timber.e(e, message)
+                LogBuffer.e(LogBuffer.TAG_BACKGROUND_TASKS, e, message)
+            }
+        }
+    }
+
+    sealed class UiState {
+        data object Loading : UiState()
+        data class Loaded(
+            val episodes: List<BaseEpisode>,
+            val isUpNextQueueEmpty: Boolean = false,
+            val useEpisodeArtwork: Boolean = false,
+        ) : UiState()
+        data object Error : UiState()
+    }
+}
diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryPage.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryPage.kt
index 2dc49ed4bf9..7dde4c7d098 100644
--- a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryPage.kt
+++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryPage.kt
@@ -77,7 +77,6 @@ private fun UpNextHistoryPageView(
     Column {
         ThemedTopAppBar(
             title = stringResource(LR.string.up_next_history),
-            bottomShadow = true,
             onNavigationClick = onBackClick,
         )
 
diff --git a/modules/features/settings/src/test/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsViewModelTest.kt b/modules/features/settings/src/test/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsViewModelTest.kt
new file mode 100644
index 00000000000..1fd8ce78fa7
--- /dev/null
+++ b/modules/features/settings/src/test/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsViewModelTest.kt
@@ -0,0 +1,119 @@
+package au.com.shiftyjelly.pocketcasts.settings.history.upnext
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.lifecycle.SavedStateHandle
+import app.cash.turbine.test
+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.preferences.Settings
+import au.com.shiftyjelly.pocketcasts.preferences.UserSetting
+import au.com.shiftyjelly.pocketcasts.preferences.model.ArtworkConfiguration
+import au.com.shiftyjelly.pocketcasts.repositories.history.upnext.UpNextHistoryManager
+import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager
+import au.com.shiftyjelly.pocketcasts.repositories.playback.UpNextQueue
+import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager
+import au.com.shiftyjelly.pocketcasts.settings.history.upnext.UpNextHistoryDetailsViewModel.UiState
+import java.util.Date
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyString
+import org.mockito.Mock
+import org.mockito.Mockito.mock
+import org.mockito.junit.MockitoJUnitRunner
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@RunWith(MockitoJUnitRunner::class)
+@ExperimentalCoroutinesApi
+class UpNextHistoryDetailsViewModelTest {
+    @get:Rule
+    val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+    @Mock
+    private lateinit var upNextHistoryManager: UpNextHistoryManager
+
+    @Mock
+    private lateinit var episodeManager: EpisodeManager
+
+    @Mock
+    private lateinit var playbackManager: PlaybackManager
+
+    @Mock
+    private lateinit var settings: Settings
+
+    @Mock
+    private lateinit var savedStateHandle: SavedStateHandle
+
+    private lateinit var viewModel: UpNextHistoryDetailsViewModel
+    private val episodes: List<BaseEpisode> = listOf(PodcastEpisode("uuid1", publishedDate = Date()), PodcastEpisode("uuid2", publishedDate = Date()))
+
+    @Before
+    fun setUp() {
+        val userSettingsArtworkConfigurationMock = mock<UserSetting<ArtworkConfiguration>>()
+        whenever(userSettingsArtworkConfigurationMock.value).thenReturn(ArtworkConfiguration(true, emptySet()))
+        whenever(settings.artworkConfiguration).thenReturn(userSettingsArtworkConfigurationMock)
+        whenever(savedStateHandle.get<Long>(anyString())).thenReturn(123456789L)
+        val upNextQueue = mock<UpNextQueue>()
+        whenever(upNextQueue.queueEpisodes).thenReturn(emptyList<BaseEpisode>())
+        whenever(playbackManager.upNextQueue).thenReturn(upNextQueue)
+    }
+
+    @Test
+    fun `given init vm, when load episodes is successful, then updates state to loaded`() = runTest {
+        val episodeUuids = episodes.map { it.uuid }
+        whenever(episodeManager.findEpisodesByUuids(episodeUuids)).thenReturn(episodes)
+        whenever(upNextHistoryManager.findEpisodeUuidsForDate(anyOrNull())).thenReturn(episodeUuids)
+
+        initViewModel()
+
+        viewModel.state.test {
+            val state = awaitItem() as UiState.Loaded
+            assertEquals(episodes, state.episodes)
+        }
+    }
+
+    @Test
+    fun `given init vm, when load episodes fails, then updates state to error`() = runTest {
+        whenever(upNextHistoryManager.findEpisodeUuidsForDate(anyOrNull())).thenThrow(RuntimeException::class.java)
+
+        initViewModel()
+
+        viewModel.state.test {
+            assertTrue(awaitItem() is UiState.Error)
+        }
+    }
+
+    @Test
+    fun `given vm init vm, when restore up next is successful, then episodes added at the end of playback queue`() = runTest {
+        val episodeUuids = episodes.map { it.uuid }
+        whenever(episodeManager.findEpisodesByUuids(episodeUuids)).thenReturn(episodes)
+        whenever(upNextHistoryManager.findEpisodeUuidsForDate(anyOrNull())).thenReturn(episodeUuids)
+        initViewModel()
+
+        viewModel.state.test {
+            viewModel.restoreUpNext()
+            awaitItem()
+            verify(playbackManager).playEpisodesLast(episodes, SourceView.UP_NEXT_HISTORY)
+        }
+    }
+
+    private fun initViewModel() {
+        viewModel = UpNextHistoryDetailsViewModel(
+            upNextHistoryManager = upNextHistoryManager,
+            episodeManager = episodeManager,
+            playbackManager = playbackManager,
+            settings = settings,
+            savedStateHandle = savedStateHandle,
+            ioDispatcher = UnconfinedTestDispatcher(),
+        )
+    }
+}
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 349db10b313..19f3a719246 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
@@ -51,6 +51,7 @@ enum class SourceView(val analyticsValue: String) {
     TASKER("tasker"),
     UNKNOWN("unknown"),
     UP_NEXT("up_next"),
+    UP_NEXT_HISTORY("up_next_history"),
     WHATS_NEW("whats_new"),
     ABOUT("about"),
     APPEARANCE("appearance_settings"),
diff --git a/modules/services/compose/src/main/java/au/com/shiftyjelly/pocketcasts/compose/components/TextStyles.kt b/modules/services/compose/src/main/java/au/com/shiftyjelly/pocketcasts/compose/components/TextStyles.kt
index 71a240dda79..17121f9d483 100644
--- a/modules/services/compose/src/main/java/au/com/shiftyjelly/pocketcasts/compose/components/TextStyles.kt
+++ b/modules/services/compose/src/main/java/au/com/shiftyjelly/pocketcasts/compose/components/TextStyles.kt
@@ -481,15 +481,17 @@ fun TextC70(
     isUpperCase: Boolean = true,
     disableAutoScale: Boolean = false,
     fontScale: Float = 1f,
+    fontSize: TextUnit = 12.sp,
+    fontWeight: FontWeight = FontWeight.W500,
 ) {
     Text(
         text = if (isUpperCase) text.uppercase(Locale.getDefault()) else text,
         color = MaterialTheme.theme.colors.primaryText02,
         fontFamily = FontFamily.SansSerif,
-        fontSize = 12.sp.scaled(disableAutoScale, fontScale),
+        fontSize = fontSize.scaled(disableAutoScale, fontScale),
         lineHeight = 14.sp.scaled(disableAutoScale, fontScale),
         letterSpacing = 0.6.sp.scaled(disableAutoScale, fontScale),
-        fontWeight = FontWeight.W500,
+        fontWeight = fontWeight,
         maxLines = maxLines,
         overflow = TextOverflow.Ellipsis,
         modifier = modifier,
diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml
index ae5c60071ef..da3d8caa50a 100644
--- a/modules/services/localization/src/main/res/values/strings.xml
+++ b/modules/services/localization/src/main/res/values/strings.xml
@@ -80,6 +80,7 @@
     <string name="remove_from_up_next">Remove from Up Next</string>
     <string name="renew_your_subscription">Renew your Subscription</string>
     <string name="removed">Removed</string>
+    <string name="restore">Restore</string>
     <string name="search">Search</string>
     <string name="search_episodes_not_found_title">No episodes found</string>
     <string name="search_episodes_not_found_summary">We couldn\'t find any episode for that search. Try another keyword.</string>
@@ -2303,4 +2304,7 @@
     <string name="restore_from_local_history">Restore from local history</string>
     <string name="up_next_history">Up Next History</string>
     <string name="up_next_history_explanation">A list of recent updates to Up Next due to changes on other devices. To view the episodes and have the option to restore them, tap any entry.</string>
+    <string name="up_next_history_restore_button">@string/restore</string>
+    <string name="up_next_history_restore_confirmation_title">Restore Up Next?</string>
+    <string name="up_next_history_restore_confirmation_description">These episodes will be added to the bottom of your current Up Next.</string>
 </resources>
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 47e2add570c..01b47cdbea0 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
@@ -541,6 +541,7 @@ open class PlaybackManager @Inject constructor(
             SourceView.TASKER,
             SourceView.UNKNOWN,
             SourceView.UP_NEXT,
+            SourceView.UP_NEXT_HISTORY,
             SourceView.FILES_SETTINGS,
             SourceView.STATS,
             SourceView.WHATS_NEW,


Reviews:
user: login: geekygecko
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-21T00:43:40Z
user: login: ashiagr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T05:40:42Z
user: login: ashiagr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T05:41:58Z
user: login: ashiagr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T05:44:34Z

Review comments:
diff_hunk: @@ -59,15 +66,54 @@ class HistoryFragment : BaseFragment(), HasBackstack {
                 composable(HistoryNavRoutes.UpNextHistory) {
                     UpNextHistoryPage(
                         onHistoryEntryClick = { date ->
+                            navController.navigate(
+                                HistoryNavRoutes.upNextHistoryDetailsDestination(
+                                    date.time,
+                                ),
+                            )
                         },
                         onBackClick = navController::popBackStack,
                         bottomInset = bottomInsetDp,
                     )
                 }
+                composable(
+                    HistoryNavRoutes.upNextHistoryDetailsRoute(),
+                    listOf(
+                        navArgument(HistoryNavRoutes.UpNextHistoryDateArgument) {
+                            type = NavType.LongType
+                        },
+                    ),
+                ) { backStackEntry ->
+                    val arguments =
+                        requireNotNull(backStackEntry.arguments) { "Missing back stack entry arguments" }
+                    val date =
+                        requireNotNull(arguments.getLong(HistoryNavRoutes.UpNextHistoryDateArgument)) {
+                            "Missing date argument"
+                        }
+                    UpNextHistoryDetailsPage(
+                        date = date,
+                        onRestoreClick = ::showUpNextRestoreConfirmationDialog,
path: modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/HistoryFragment.kt
user: login: geekygecko
body: This isn't a big deal, so feel free to ignore it, but if I don't have anything currently playing or in my Up Next, the explanation text doesn't really make sense. Maybe it can just be restored. 

<img  width="300" src="https://github.com/user-attachments/assets/4e4b5ef9-f26d-4b61-8960-bb85ced0dca1" /> 




created_at: 2025-03-21T00:30:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,245 @@
+package au.com.shiftyjelly.pocketcasts.settings.history.upnext
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground
+import au.com.shiftyjelly.pocketcasts.compose.Devices
+import au.com.shiftyjelly.pocketcasts.compose.bars.ThemedTopAppBar
+import au.com.shiftyjelly.pocketcasts.compose.components.EpisodeImage
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH40
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH50
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH70
+import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView
+import au.com.shiftyjelly.pocketcasts.compose.preview.ThemePreviewParameterProvider
+import au.com.shiftyjelly.pocketcasts.localization.helper.RelativeDateFormatter
+import au.com.shiftyjelly.pocketcasts.localization.helper.TimeHelper
+import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode
+import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode
+import au.com.shiftyjelly.pocketcasts.settings.history.upnext.UpNextHistoryDetailsViewModel.UiState
+import au.com.shiftyjelly.pocketcasts.ui.theme.Theme
+import java.text.DateFormat
+import java.util.Date
+import au.com.shiftyjelly.pocketcasts.localization.R as LR
+
+@Composable
+fun UpNextHistoryDetailsPage(
+    viewModel: UpNextHistoryDetailsViewModel = hiltViewModel(),
+    date: Long,
+    onRestoreClick: (restoreUpNext: () -> Unit) -> Unit,
+    onBackClick: () -> Unit,
+    bottomInset: Dp,
+) {
+    val state by viewModel.state.collectAsStateWithLifecycle()
+    UpNextHistoryDetailsView(
+        date = date,
+        state = state,
+        onRestoreClick = { onRestoreClick { viewModel.restoreUpNext() } },
+        onBackClick = onBackClick,
+        bottomInset = bottomInset,
+    )
+}
+
+@Composable
+private fun UpNextHistoryDetailsView(
+    date: Long,
+    state: UiState,
+    onRestoreClick: () -> Unit,
+    onBackClick: () -> Unit,
+    bottomInset: Dp,
+) {
+    Column {
+        ThemedTopAppBar(
+            title = formatDate(date),
+            bottomShadow = true,
path: modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsPage.kt
user: login: geekygecko
body: ```suggestion
```

I think it looks more modern to not have the drop shadow. Feel free to ignore if you disagree. 
created_at: 2025-03-21T00:32:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,245 @@
+package au.com.shiftyjelly.pocketcasts.settings.history.upnext
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground
+import au.com.shiftyjelly.pocketcasts.compose.Devices
+import au.com.shiftyjelly.pocketcasts.compose.bars.ThemedTopAppBar
+import au.com.shiftyjelly.pocketcasts.compose.components.EpisodeImage
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH40
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH50
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH70
+import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView
+import au.com.shiftyjelly.pocketcasts.compose.preview.ThemePreviewParameterProvider
+import au.com.shiftyjelly.pocketcasts.localization.helper.RelativeDateFormatter
+import au.com.shiftyjelly.pocketcasts.localization.helper.TimeHelper
+import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode
+import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode
+import au.com.shiftyjelly.pocketcasts.settings.history.upnext.UpNextHistoryDetailsViewModel.UiState
+import au.com.shiftyjelly.pocketcasts.ui.theme.Theme
+import java.text.DateFormat
+import java.util.Date
+import au.com.shiftyjelly.pocketcasts.localization.R as LR
+
+@Composable
+fun UpNextHistoryDetailsPage(
+    viewModel: UpNextHistoryDetailsViewModel = hiltViewModel(),
+    date: Long,
+    onRestoreClick: (restoreUpNext: () -> Unit) -> Unit,
+    onBackClick: () -> Unit,
+    bottomInset: Dp,
+) {
+    val state by viewModel.state.collectAsStateWithLifecycle()
+    UpNextHistoryDetailsView(
+        date = date,
+        state = state,
+        onRestoreClick = { onRestoreClick { viewModel.restoreUpNext() } },
+        onBackClick = onBackClick,
+        bottomInset = bottomInset,
+    )
+}
+
+@Composable
+private fun UpNextHistoryDetailsView(
+    date: Long,
+    state: UiState,
+    onRestoreClick: () -> Unit,
+    onBackClick: () -> Unit,
+    bottomInset: Dp,
+) {
+    Column {
+        ThemedTopAppBar(
+            title = formatDate(date),
+            bottomShadow = true,
+            onNavigationClick = onBackClick,
+            actions = { iconColor ->
+                TextButton(
+                    onClick = onRestoreClick,
+                    enabled = state is UiState.Loaded,
+                ) {
+                    TextH50(text = stringResource(LR.string.restore))
+                }
+            },
+        )
+        when (state) {
+            is UiState.Loading -> LoadingView()
+            is UiState.Loaded -> {
+                UpNextHistoryEpisodes(
+                    state = state,
+                    bottomInset = bottomInset,
+                )
+            }
+
+            is UiState.Error -> UpNextHistoryErrorView()
+        }
+    }
+}
+
+@Composable
+private fun UpNextHistoryEpisodes(
+    state: UiState.Loaded,
+    bottomInset: Dp,
+    modifier: Modifier = Modifier,
+) {
+    LazyColumn(
+        modifier
+            .fillMaxHeight()
+            .padding(16.dp),
+        contentPadding = PaddingValues(bottom = bottomInset),
+    ) {
+        items(state.episodes) { episode ->
+            EpisodeRow(
+                episode = episode,
+                useEpisodeArtwork = state.useEpisodeArtwork,
+            )
+        }
+    }
+}
+
+@Composable
+private fun UpNextHistoryErrorView() {
+    Box(
+        modifier = Modifier.fillMaxSize(),
+        contentAlignment = Alignment.Center,
+    ) {
+        TextH50(
+            text = stringResource(LR.string.error_generic_message),
+            modifier = Modifier.padding(horizontal = 16.dp),
+        )
+    }
+}
+
+@Composable
+private fun EpisodeRow(
+    episode: BaseEpisode,
+    useEpisodeArtwork: Boolean,
+    modifier: Modifier = Modifier,
+) {
+    val context = LocalContext.current
+    val formattedDuration = remember(episode.durationMs) {
+        TimeHelper.getTimeDurationMediumString(
+            episode.durationMs,
+            context,
+        )
+    }
+    val published = remember(episode.durationMs) {
+        val dateFormatter = RelativeDateFormatter(context)
+        dateFormatter.format(episode.publishedDate)
+    }
+
+    Row(
+        verticalAlignment = Alignment.CenterVertically,
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(vertical = 4.dp),
+    ) {
+        EpisodeImage(
+            episode = episode,
+            useEpisodeArtwork = useEpisodeArtwork,
+            modifier = modifier.size(56.dp),
+        )
+
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .padding(start = 16.dp),
+        ) {
+            TextH70(
+                text = published,
+                maxLines = 1,
+                modifier = Modifier.padding(bottom = 4.dp),
+            )
+
+            TextH40(
+                text = episode.title,
+                maxLines = 1,
+            )
+
+            TextH70(
+                text = formattedDuration,
+                maxLines = 1,
+                modifier = Modifier.padding(top = 4.dp),
+            )
path: modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsPage.kt
user: login: geekygecko
body: Can we make these look more like the episode row from filters?

| | |
| --- | --- |
| <img width="317" alt="Screenshot 2025-03-21 at 11 04 59 am" src="https://github.com/user-attachments/assets/38b7f3eb-57e7-4f71-8bd1-728b7b411454" /> | <img width="288" alt="Screenshot 2025-03-21 at 11 03 51 am" src="https://github.com/user-attachments/assets/a3a55879-f143-4ac7-b166-ba3960523fc7" /> |  




created_at: 2025-03-21T00:35:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,245 @@
+package au.com.shiftyjelly.pocketcasts.settings.history.upnext
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground
+import au.com.shiftyjelly.pocketcasts.compose.Devices
+import au.com.shiftyjelly.pocketcasts.compose.bars.ThemedTopAppBar
+import au.com.shiftyjelly.pocketcasts.compose.components.EpisodeImage
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH40
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH50
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH70
+import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView
+import au.com.shiftyjelly.pocketcasts.compose.preview.ThemePreviewParameterProvider
+import au.com.shiftyjelly.pocketcasts.localization.helper.RelativeDateFormatter
+import au.com.shiftyjelly.pocketcasts.localization.helper.TimeHelper
+import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode
+import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode
+import au.com.shiftyjelly.pocketcasts.settings.history.upnext.UpNextHistoryDetailsViewModel.UiState
+import au.com.shiftyjelly.pocketcasts.ui.theme.Theme
+import java.text.DateFormat
+import java.util.Date
+import au.com.shiftyjelly.pocketcasts.localization.R as LR
+
+@Composable
+fun UpNextHistoryDetailsPage(
+    viewModel: UpNextHistoryDetailsViewModel = hiltViewModel(),
+    date: Long,
+    onRestoreClick: (restoreUpNext: () -> Unit) -> Unit,
+    onBackClick: () -> Unit,
+    bottomInset: Dp,
+) {
+    val state by viewModel.state.collectAsStateWithLifecycle()
+    UpNextHistoryDetailsView(
+        date = date,
+        state = state,
+        onRestoreClick = { onRestoreClick { viewModel.restoreUpNext() } },
+        onBackClick = onBackClick,
+        bottomInset = bottomInset,
+    )
+}
+
+@Composable
+private fun UpNextHistoryDetailsView(
+    date: Long,
+    state: UiState,
+    onRestoreClick: () -> Unit,
+    onBackClick: () -> Unit,
+    bottomInset: Dp,
+) {
+    Column {
+        ThemedTopAppBar(
+            title = formatDate(date),
+            bottomShadow = true,
+            onNavigationClick = onBackClick,
+            actions = { iconColor ->
+                TextButton(
+                    onClick = onRestoreClick,
+                    enabled = state is UiState.Loaded,
+                ) {
+                    TextH50(text = stringResource(LR.string.restore))
+                }
+            },
+        )
+        when (state) {
+            is UiState.Loading -> LoadingView()
+            is UiState.Loaded -> {
+                UpNextHistoryEpisodes(
+                    state = state,
+                    bottomInset = bottomInset,
+                )
+            }
+
+            is UiState.Error -> UpNextHistoryErrorView()
+        }
+    }
+}
+
+@Composable
+private fun UpNextHistoryEpisodes(
+    state: UiState.Loaded,
+    bottomInset: Dp,
+    modifier: Modifier = Modifier,
+) {
+    LazyColumn(
+        modifier
+            .fillMaxHeight()
+            .padding(16.dp),
+        contentPadding = PaddingValues(bottom = bottomInset),
+    ) {
+        items(state.episodes) { episode ->
+            EpisodeRow(
+                episode = episode,
+                useEpisodeArtwork = state.useEpisodeArtwork,
+            )
+        }
+    }
path: modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsPage.kt
user: login: geekygecko
body: I can't see the bottom episode and the top episode is being cut off early.

![Screenshot_20250321_111102](https://github.com/user-attachments/assets/ce26a3e0-460c-4182-9630-78cc59af1822)

created_at: 2025-03-21T00:42:27Z
author_association: MEMBER
diff_hunk: @@ -59,15 +66,54 @@ class HistoryFragment : BaseFragment(), HasBackstack {
                 composable(HistoryNavRoutes.UpNextHistory) {
                     UpNextHistoryPage(
                         onHistoryEntryClick = { date ->
+                            navController.navigate(
+                                HistoryNavRoutes.upNextHistoryDetailsDestination(
+                                    date.time,
+                                ),
+                            )
                         },
                         onBackClick = navController::popBackStack,
                         bottomInset = bottomInsetDp,
                     )
                 }
+                composable(
+                    HistoryNavRoutes.upNextHistoryDetailsRoute(),
+                    listOf(
+                        navArgument(HistoryNavRoutes.UpNextHistoryDateArgument) {
+                            type = NavType.LongType
+                        },
+                    ),
+                ) { backStackEntry ->
+                    val arguments =
+                        requireNotNull(backStackEntry.arguments) { "Missing back stack entry arguments" }
+                    val date =
+                        requireNotNull(arguments.getLong(HistoryNavRoutes.UpNextHistoryDateArgument)) {
+                            "Missing date argument"
+                        }
+                    UpNextHistoryDetailsPage(
+                        date = date,
+                        onRestoreClick = ::showUpNextRestoreConfirmationDialog,
path: modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/HistoryFragment.kt
user: login: ashiagr
body: Good point! Why add an extra step? Flow updated in fb33c2e
created_at: 2025-03-21T05:40:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,245 @@
+package au.com.shiftyjelly.pocketcasts.settings.history.upnext
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground
+import au.com.shiftyjelly.pocketcasts.compose.Devices
+import au.com.shiftyjelly.pocketcasts.compose.bars.ThemedTopAppBar
+import au.com.shiftyjelly.pocketcasts.compose.components.EpisodeImage
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH40
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH50
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH70
+import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView
+import au.com.shiftyjelly.pocketcasts.compose.preview.ThemePreviewParameterProvider
+import au.com.shiftyjelly.pocketcasts.localization.helper.RelativeDateFormatter
+import au.com.shiftyjelly.pocketcasts.localization.helper.TimeHelper
+import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode
+import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode
+import au.com.shiftyjelly.pocketcasts.settings.history.upnext.UpNextHistoryDetailsViewModel.UiState
+import au.com.shiftyjelly.pocketcasts.ui.theme.Theme
+import java.text.DateFormat
+import java.util.Date
+import au.com.shiftyjelly.pocketcasts.localization.R as LR
+
+@Composable
+fun UpNextHistoryDetailsPage(
+    viewModel: UpNextHistoryDetailsViewModel = hiltViewModel(),
+    date: Long,
+    onRestoreClick: (restoreUpNext: () -> Unit) -> Unit,
+    onBackClick: () -> Unit,
+    bottomInset: Dp,
+) {
+    val state by viewModel.state.collectAsStateWithLifecycle()
+    UpNextHistoryDetailsView(
+        date = date,
+        state = state,
+        onRestoreClick = { onRestoreClick { viewModel.restoreUpNext() } },
+        onBackClick = onBackClick,
+        bottomInset = bottomInset,
+    )
+}
+
+@Composable
+private fun UpNextHistoryDetailsView(
+    date: Long,
+    state: UiState,
+    onRestoreClick: () -> Unit,
+    onBackClick: () -> Unit,
+    bottomInset: Dp,
+) {
+    Column {
+        ThemedTopAppBar(
+            title = formatDate(date),
+            bottomShadow = true,
+            onNavigationClick = onBackClick,
+            actions = { iconColor ->
+                TextButton(
+                    onClick = onRestoreClick,
+                    enabled = state is UiState.Loaded,
+                ) {
+                    TextH50(text = stringResource(LR.string.restore))
+                }
+            },
+        )
+        when (state) {
+            is UiState.Loading -> LoadingView()
+            is UiState.Loaded -> {
+                UpNextHistoryEpisodes(
+                    state = state,
+                    bottomInset = bottomInset,
+                )
+            }
+
+            is UiState.Error -> UpNextHistoryErrorView()
+        }
+    }
+}
+
+@Composable
+private fun UpNextHistoryEpisodes(
+    state: UiState.Loaded,
+    bottomInset: Dp,
+    modifier: Modifier = Modifier,
+) {
+    LazyColumn(
+        modifier
+            .fillMaxHeight()
+            .padding(16.dp),
+        contentPadding = PaddingValues(bottom = bottomInset),
+    ) {
+        items(state.episodes) { episode ->
+            EpisodeRow(
+                episode = episode,
+                useEpisodeArtwork = state.useEpisodeArtwork,
+            )
+        }
+    }
+}
+
+@Composable
+private fun UpNextHistoryErrorView() {
+    Box(
+        modifier = Modifier.fillMaxSize(),
+        contentAlignment = Alignment.Center,
+    ) {
+        TextH50(
+            text = stringResource(LR.string.error_generic_message),
+            modifier = Modifier.padding(horizontal = 16.dp),
+        )
+    }
+}
+
+@Composable
+private fun EpisodeRow(
+    episode: BaseEpisode,
+    useEpisodeArtwork: Boolean,
+    modifier: Modifier = Modifier,
+) {
+    val context = LocalContext.current
+    val formattedDuration = remember(episode.durationMs) {
+        TimeHelper.getTimeDurationMediumString(
+            episode.durationMs,
+            context,
+        )
+    }
+    val published = remember(episode.durationMs) {
+        val dateFormatter = RelativeDateFormatter(context)
+        dateFormatter.format(episode.publishedDate)
+    }
+
+    Row(
+        verticalAlignment = Alignment.CenterVertically,
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(vertical = 4.dp),
+    ) {
+        EpisodeImage(
+            episode = episode,
+            useEpisodeArtwork = useEpisodeArtwork,
+            modifier = modifier.size(56.dp),
+        )
+
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .padding(start = 16.dp),
+        ) {
+            TextH70(
+                text = published,
+                maxLines = 1,
+                modifier = Modifier.padding(bottom = 4.dp),
+            )
+
+            TextH40(
+                text = episode.title,
+                maxLines = 1,
+            )
+
+            TextH70(
+                text = formattedDuration,
+                maxLines = 1,
+                modifier = Modifier.padding(top = 4.dp),
+            )
path: modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsPage.kt
user: login: ashiagr
body: Updated in 3980833

<img width = 300  src="https://github.com/user-attachments/assets/6a6a21dc-c425-4625-9533-bde6f12ef625"/>

created_at: 2025-03-21T05:41:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,245 @@
+package au.com.shiftyjelly.pocketcasts.settings.history.upnext
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground
+import au.com.shiftyjelly.pocketcasts.compose.Devices
+import au.com.shiftyjelly.pocketcasts.compose.bars.ThemedTopAppBar
+import au.com.shiftyjelly.pocketcasts.compose.components.EpisodeImage
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH40
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH50
+import au.com.shiftyjelly.pocketcasts.compose.components.TextH70
+import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView
+import au.com.shiftyjelly.pocketcasts.compose.preview.ThemePreviewParameterProvider
+import au.com.shiftyjelly.pocketcasts.localization.helper.RelativeDateFormatter
+import au.com.shiftyjelly.pocketcasts.localization.helper.TimeHelper
+import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode
+import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode
+import au.com.shiftyjelly.pocketcasts.settings.history.upnext.UpNextHistoryDetailsViewModel.UiState
+import au.com.shiftyjelly.pocketcasts.ui.theme.Theme
+import java.text.DateFormat
+import java.util.Date
+import au.com.shiftyjelly.pocketcasts.localization.R as LR
+
+@Composable
+fun UpNextHistoryDetailsPage(
+    viewModel: UpNextHistoryDetailsViewModel = hiltViewModel(),
+    date: Long,
+    onRestoreClick: (restoreUpNext: () -> Unit) -> Unit,
+    onBackClick: () -> Unit,
+    bottomInset: Dp,
+) {
+    val state by viewModel.state.collectAsStateWithLifecycle()
+    UpNextHistoryDetailsView(
+        date = date,
+        state = state,
+        onRestoreClick = { onRestoreClick { viewModel.restoreUpNext() } },
+        onBackClick = onBackClick,
+        bottomInset = bottomInset,
+    )
+}
+
+@Composable
+private fun UpNextHistoryDetailsView(
+    date: Long,
+    state: UiState,
+    onRestoreClick: () -> Unit,
+    onBackClick: () -> Unit,
+    bottomInset: Dp,
+) {
+    Column {
+        ThemedTopAppBar(
+            title = formatDate(date),
+            bottomShadow = true,
+            onNavigationClick = onBackClick,
+            actions = { iconColor ->
+                TextButton(
+                    onClick = onRestoreClick,
+                    enabled = state is UiState.Loaded,
+                ) {
+                    TextH50(text = stringResource(LR.string.restore))
+                }
+            },
+        )
+        when (state) {
+            is UiState.Loading -> LoadingView()
+            is UiState.Loaded -> {
+                UpNextHistoryEpisodes(
+                    state = state,
+                    bottomInset = bottomInset,
+                )
+            }
+
+            is UiState.Error -> UpNextHistoryErrorView()
+        }
+    }
+}
+
+@Composable
+private fun UpNextHistoryEpisodes(
+    state: UiState.Loaded,
+    bottomInset: Dp,
+    modifier: Modifier = Modifier,
+) {
+    LazyColumn(
+        modifier
+            .fillMaxHeight()
+            .padding(16.dp),
+        contentPadding = PaddingValues(bottom = bottomInset),
+    ) {
+        items(state.episodes) { episode ->
+            EpisodeRow(
+                episode = episode,
+                useEpisodeArtwork = state.useEpisodeArtwork,
+            )
+        }
+    }
path: modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/history/upnext/UpNextHistoryDetailsPage.kt
user: login: ashiagr
body: > I can't see the bottom episode 

I see that bottom insets are added to the view. I can't reproduce it. Maybe padding updates for the top row fixed it. 

https://github.com/user-attachments/assets/8b97220e-6c6d-4dd6-9f37-579781528cdd

created_at: 2025-03-21T05:44:33Z
author_association: CONTRIBUTOR

PR comments:
user: login: dangermattic
created_at: 2025-03-20T06:28:19Z
body: <!--
  0 Errors
  1 Warning: This PR is larger than 500 lin...
  0 Messages
  0 Markdowns
-->
<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true" data-kind="Warning">
          1 Warning
      </th>
     </tr>
  </thead>
  <tbody>
    <tr>
      <td>:warning:</td>
      <td data-sticky="false">This PR is larger than 500 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.</td>
    </tr>
  </tbody>
</table>

<p align="right" data-meta="generated_by_pr-check">
  Generated by :no_entry_sign: <a href="https://danger.systems/">Danger</a>
</p>

author_association: COLLABORATOR
user: login: wpmobilebot
created_at: 2025-03-20T06:29:17Z
body: <!-- REUSE_ID: app-prototype-build-link -->

<details><summary>📲 You can test the changes from this Pull Request in <b>📱 Mobile</b> by scanning the QR code below to install the corresponding build.</summary>
<table>
<tr>
  <td rowspan='4' width='260px'><img src='https://api.qrserver.com/v1/create-qr-code/?size=500x500&qzone=4&data=https%3A%2F%2Fcdn.a8c-ci.services%2F8af446086b2a2e077ae6d4c1b8fadd0fdea615fb%2Fpocketcasts-app-prototype-build-pr3797-8e4121a.apk' width='250' height='250' /></td>
  <td><b>App Name</b></td><td> 📱 Mobile</td>
</tr>
<tr><td><b>Build Type</b></td><td>debugProd</td></tr>
<tr><td><b>Commit</b></td><td>8e4121aa12db31714b8e806cc01fa15bde470063</td></tr>
<tr><td><b>Direct Download</b></td><td><a href='https://cdn.a8c-ci.services/8af446086b2a2e077ae6d4c1b8fadd0fdea615fb/pocketcasts-app-prototype-build-pr3797-8e4121a.apk'><code>pocketcasts-app-prototype-build-pr3797-8e4121a.apk</code></a></td></tr>
</table>

</details>
<details><summary>📲 You can test the changes from this Pull Request in <b>🚗 Automotive</b> by scanning the QR code below to install the corresponding build.</summary>
<table>
<tr>
  <td rowspan='4' width='260px'><img src='https://api.qrserver.com/v1/create-qr-code/?size=500x500&qzone=4&data=https%3A%2F%2Fcdn.a8c-ci.services%2F4ee1b6d78a0959a907f50a696d705f8c75f56c77%2Fpocketcasts-automotive-prototype-build-pr3797-8e4121a.apk' width='250' height='250' /></td>
  <td><b>App Name</b></td><td> 🚗 Automotive</td>
</tr>
<tr><td><b>Build Type</b></td><td>debugProd</td></tr>
<tr><td><b>Commit</b></td><td>8e4121aa12db31714b8e806cc01fa15bde470063</td></tr>
<tr><td><b>Direct Download</b></td><td><a href='https://cdn.a8c-ci.services/4ee1b6d78a0959a907f50a696d705f8c75f56c77/pocketcasts-automotive-prototype-build-pr3797-8e4121a.apk'><code>pocketcasts-automotive-prototype-build-pr3797-8e4121a.apk</code></a></td></tr>
</table>

</details>
<details><summary>📲 You can test the changes from this Pull Request in <b>⌚ Wear</b> by scanning the QR code below to install the corresponding build.</summary>
<table>
<tr>
  <td rowspan='4' width='260px'><img src='https://api.qrserver.com/v1/create-qr-code/?size=500x500&qzone=4&data=https%3A%2F%2Fcdn.a8c-ci.services%2Ffed995626e931f22c8a6527d801082253889a6fc%2Fpocketcasts-wear-prototype-build-pr3797-8e4121a.apk' width='250' height='250' /></td>
  <td><b>App Name</b></td><td> ⌚ Wear</td>
</tr>
<tr><td><b>Build Type</b></td><td>debugProd</td></tr>
<tr><td><b>Commit</b></td><td>8e4121aa12db31714b8e806cc01fa15bde470063</td></tr>
<tr><td><b>Direct Download</b></td><td><a href='https://cdn.a8c-ci.services/fed995626e931f22c8a6527d801082253889a6fc/pocketcasts-wear-prototype-build-pr3797-8e4121a.apk'><code>pocketcasts-wear-prototype-build-pr3797-8e4121a.apk</code></a></td></tr>
</table>

</details>

author_association: COLLABORATOR