diff --git a/modules/features/endofyear/build.gradle b/modules/features/endofyear/build.gradle index 7ea247923de..cd8894f0f45 100644 --- a/modules/features/endofyear/build.gradle +++ b/modules/features/endofyear/build.gradle @@ -14,5 +14,6 @@ dependencies { implementation project(':modules:services:compose') implementation project(':modules:services:localization') implementation project(':modules:services:ui') + implementation project(':modules:services:utils') implementation project(':modules:services:views') } diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesDataSource.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesDataSource.kt new file mode 100644 index 00000000000..a72ea649b33 --- /dev/null +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesDataSource.kt @@ -0,0 +1,16 @@ +package au.com.shiftyjelly.pocketcasts.endofyear + +import au.com.shiftyjelly.pocketcasts.endofyear.stories.Story +import kotlinx.coroutines.flow.Flow + +abstract class StoriesDataSource { + protected abstract val stories: List + + val numOfStories: Int + get() = stories.size + val totalLengthInMs + get() = stories.sumOf { it.storyLength } + + abstract suspend fun loadStories(): Flow> + abstract fun storyAt(index: Int): Story? +} diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesFragment.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesFragment.kt index 0bfc6445530..85f3ecd87ea 100644 --- a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesFragment.kt +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesFragment.kt @@ -7,12 +7,14 @@ import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.viewModels import au.com.shiftyjelly.pocketcasts.compose.AppTheme import au.com.shiftyjelly.pocketcasts.ui.R import au.com.shiftyjelly.pocketcasts.ui.helper.StatusBarColor import au.com.shiftyjelly.pocketcasts.views.fragments.BaseDialogFragment class StoriesFragment : BaseDialogFragment() { + private val viewModel: StoriesViewModel by viewModels() override val statusBarColor: StatusBarColor get() = StatusBarColor.Custom(Color.BLACK, true) @@ -31,6 +33,7 @@ class StoriesFragment : BaseDialogFragment() { AppTheme(theme.activeTheme) { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) StoriesScreen( + viewModel = viewModel, onCloseClicked = { dismiss() }, ) } diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesScreen.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesScreen.kt index c73a284ecb1..4eacb7076ed 100644 --- a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesScreen.kt +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesScreen.kt @@ -1,10 +1,9 @@ package au.com.shiftyjelly.pocketcasts.endofyear -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween +import androidx.annotation.FloatRange import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,19 +13,23 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Share import androidx.compose.runtime.Composable -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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -34,48 +37,101 @@ import androidx.compose.ui.unit.dp import au.com.shiftyjelly.pocketcasts.compose.AppTheme import au.com.shiftyjelly.pocketcasts.compose.bars.NavigationButton import au.com.shiftyjelly.pocketcasts.compose.buttons.RowOutlinedButton +import au.com.shiftyjelly.pocketcasts.compose.components.TextP50 import au.com.shiftyjelly.pocketcasts.compose.preview.ThemePreviewParameterProvider +import au.com.shiftyjelly.pocketcasts.endofyear.StoriesViewModel.State +import au.com.shiftyjelly.pocketcasts.endofyear.stories.Story +import au.com.shiftyjelly.pocketcasts.endofyear.stories.StoryFake1 import au.com.shiftyjelly.pocketcasts.ui.theme.Theme import au.com.shiftyjelly.pocketcasts.localization.R as LR -private const val ProgressDurationMs = 5_000 private val ShareButtonStrokeWidth = 2.dp private val StoryViewCornerSize = 10.dp -private const val NumberOfSegments = 2 @Composable fun StoriesScreen( + viewModel: StoriesViewModel, onCloseClicked: () -> Unit, - modifier: Modifier = Modifier, ) { - var running by remember { mutableStateOf(false) } - val progress: Float by animateFloatAsState( - if (running) 1f else 0f, - animationSpec = tween( - durationMillis = ProgressDurationMs, - easing = LinearEasing + val state: State by viewModel.state.collectAsState() + val progress: Float by viewModel.progress.collectAsState() + when (state) { + is State.Loaded -> StoriesView( + state = state as State.Loaded, + progress = progress, + onSkipPrevious = { viewModel.skipPrevious() }, + onSkipNext = { viewModel.skipNext() }, + onPause = { viewModel.pause() }, + onStart = { viewModel.start() }, + onCloseClicked = onCloseClicked ) - ) - Box(modifier = modifier.background(color = Color.Black)) { - StoryView(color = Color.Gray) + State.Loading -> StoriesLoadingView(onCloseClicked) + State.Error -> StoriesErrorView(onCloseClicked) + } +} + +@Composable +private fun StoriesView( + state: State.Loaded, + @FloatRange(from = 0.0, to = 1.0) progress: Float, + onSkipPrevious: () -> Unit, + onSkipNext: () -> Unit, + onPause: () -> Unit, + onStart: () -> Unit, + onCloseClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + var screenWidth by remember { mutableStateOf(1) } + var isPaused by remember { mutableStateOf(false) } + Box( + modifier = modifier + .fillMaxSize() + .background(color = Color.Black) + .onGloballyPositioned { + screenWidth = it.size.width + } + .pointerInput(Unit) { + detectTapGestures( + onTap = { + if (!isPaused) { + if (it.x > screenWidth / 2) { + onSkipNext() + } else { + onSkipPrevious() + } + } + }, + onLongPress = { + isPaused = true + onPause() + }, + onPress = { + awaitRelease() + if (isPaused) { + onStart() + isPaused = false + } + } + ) + } + ) { + state.currentStory?.let { + StoryView(it) + } SegmentedProgressIndicator( progress = progress, - numberOfSegments = NumberOfSegments, + numberOfSegments = state.numberOfStories, modifier = modifier .padding(8.dp) .fillMaxWidth(), ) CloseButtonView(onCloseClicked) } - - LaunchedEffect(Unit) { - running = true - } } @Composable private fun StoryView( - color: Color, + story: Story, modifier: Modifier = Modifier, ) { Column { @@ -84,7 +140,7 @@ private fun StoryView( .fillMaxSize() .weight(weight = 1f, fill = true) .clip(RoundedCornerShape(StoryViewCornerSize)) - .background(color = color) + .background(color = story.backgroundColor) ) {} ShareButton() } @@ -128,13 +184,93 @@ private fun CloseButtonView( } } +@Composable +private fun StoriesLoadingView( + onCloseClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + StoriesEmptyView( + content = { CircularProgressIndicator(color = Color.White) }, + onCloseClicked = onCloseClicked, + modifier = modifier + ) +} + +@Composable +private fun StoriesErrorView( + onCloseClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + StoriesEmptyView( + content = { + TextP50( + text = "Failed to load stories.", // TODO: replace hardcoded text + color = Color.White, + ) + }, + onCloseClicked = onCloseClicked, + modifier = modifier + ) +} + +@Composable +private fun StoriesEmptyView( + onCloseClicked: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit = {}, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(color = Color.Black) + ) { + CloseButtonView(onCloseClicked) + Box( + contentAlignment = Alignment.Center, + modifier = modifier.fillMaxSize() + ) { + content() + } + } +} + @Preview(showBackground = true) @Composable private fun StoriesScreenPreview( @PreviewParameter(ThemePreviewParameterProvider::class) themeType: Theme.ThemeType, ) { AppTheme(themeType) { - StoriesScreen( + StoriesView( + state = State.Loaded(currentStory = StoryFake1(), numberOfStories = 2), + progress = 0.5f, + onSkipPrevious = {}, + onSkipNext = {}, + onPause = {}, + onStart = {}, + onCloseClicked = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun StoriesLoadingViewPreview( + @PreviewParameter(ThemePreviewParameterProvider::class) themeType: Theme.ThemeType, +) { + AppTheme(themeType) { + StoriesLoadingView( + onCloseClicked = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun StoriesErrorViewPreview( + @PreviewParameter(ThemePreviewParameterProvider::class) themeType: Theme.ThemeType, +) { + AppTheme(themeType) { + StoriesErrorView( onCloseClicked = {} ) } diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesViewModel.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesViewModel.kt new file mode 100644 index 00000000000..956d2c54070 --- /dev/null +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesViewModel.kt @@ -0,0 +1,122 @@ +package au.com.shiftyjelly.pocketcasts.endofyear + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import au.com.shiftyjelly.pocketcasts.endofyear.stories.Story +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.util.Timer +import javax.inject.Inject +import kotlin.concurrent.fixedRateTimer +import kotlin.math.roundToInt + +@HiltViewModel +class StoriesViewModel @Inject constructor( + private val storiesDataSource: StoriesDataSource, +) : ViewModel() { + private val mutableState = MutableStateFlow(State.Loading) + val state: StateFlow = mutableState + + private val mutableProgress = MutableStateFlow(0f) + val progress: StateFlow = mutableProgress + + private val numOfStories: Int + get() = storiesDataSource.numOfStories + + private var currentIndex: Int = 0 + private val nextIndex + get() = (currentIndex.plus(1)).coerceAtMost(numOfStories.minus(1)) + + private var timer: Timer? = null + + init { + viewModelScope.launch { + storiesDataSource.loadStories().collect { stories -> + val state = if (stories.isEmpty()) { + State.Error + } else { + State.Loaded( + currentStory = storiesDataSource.storyAt(currentIndex), + numberOfStories = numOfStories + ) + } + mutableState.value = state + if (state is State.Loaded) start() + } + } + } + + fun start() { + val currentState = state.value as State.Loaded + val progressFraction = + (PROGRESS_UPDATE_INTERVAL_MS / storiesDataSource.totalLengthInMs.toFloat()) + .coerceAtMost(PROGRESS_END_VALUE) + + timer = fixedRateTimer(period = PROGRESS_UPDATE_INTERVAL_MS) { + val newProgress = (progress.value + progressFraction) + .coerceIn(PROGRESS_START_VALUE, PROGRESS_END_VALUE) + + if (newProgress.roundOff() == getXStartOffsetAtIndex(nextIndex).roundOff()) { + currentIndex = nextIndex + mutableState.value = + currentState.copy(currentStory = storiesDataSource.storyAt(currentIndex)) + } + + mutableProgress.value = newProgress + if (newProgress == PROGRESS_END_VALUE) cancelTimer() + } + } + + fun skipPrevious() { + val prevIndex = (currentIndex.minus(1)).coerceAtLeast(0) + skipToStoryAtIndex(prevIndex) + } + + fun skipNext() { + skipToStoryAtIndex(nextIndex) + } + + fun pause() { + cancelTimer() + } + + private fun skipToStoryAtIndex(index: Int) { + if (timer == null) start() + mutableProgress.value = getXStartOffsetAtIndex(index) + mutableState.value = + (state.value as State.Loaded).copy(currentStory = storiesDataSource.storyAt(index)) + } + + private fun cancelTimer() { + timer?.cancel() + timer = null + } + + override fun onCleared() { + super.onCleared() + cancelTimer() + } + + private fun Float.roundOff() = (this * 100.0).roundToInt() + + private fun getXStartOffsetAtIndex(index: Int) = + (PROGRESS_END_VALUE / numOfStories.toFloat()).coerceAtMost(PROGRESS_END_VALUE) * index + + sealed class State { + object Loading : State() + data class Loaded( + val currentStory: Story?, + val numberOfStories: Int, + ) : State() + + object Error : State() + } + + companion object { + private const val PROGRESS_START_VALUE = 0f + private const val PROGRESS_END_VALUE = 1f + private const val PROGRESS_UPDATE_INTERVAL_MS = 10L + } +} diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/di/EndOfYearModule.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/di/EndOfYearModule.kt new file mode 100644 index 00000000000..95190b70b10 --- /dev/null +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/di/EndOfYearModule.kt @@ -0,0 +1,15 @@ +package au.com.shiftyjelly.pocketcasts.endofyear.di + +import au.com.shiftyjelly.pocketcasts.endofyear.StoriesDataSource +import au.com.shiftyjelly.pocketcasts.endofyear.stories.EndOfYearStoriesDataSource +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class EndOfYearModule { + @Binds + abstract fun providesEndOfYearStoriesDataSource(dataSource: EndOfYearStoriesDataSource): StoriesDataSource +} diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/stories/EndOfYearStoriesDataSource.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/stories/EndOfYearStoriesDataSource.kt new file mode 100644 index 00000000000..e82253d044c --- /dev/null +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/stories/EndOfYearStoriesDataSource.kt @@ -0,0 +1,25 @@ +package au.com.shiftyjelly.pocketcasts.endofyear.stories + +import au.com.shiftyjelly.pocketcasts.endofyear.StoriesDataSource +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import timber.log.Timber +import javax.inject.Inject + +class EndOfYearStoriesDataSource @Inject constructor() : StoriesDataSource() { + override val stories = mutableListOf() + + override suspend fun loadStories(): Flow> { + delay(1000L) // TODO: Remove hardcoded delay added for testing + stories.addAll(listOf(StoryFake1(), StoryFake2())) + return flowOf(stories) + } + + override fun storyAt(index: Int) = try { + stories[index] + } catch (e: IndexOutOfBoundsException) { + Timber.e("Story index is out of bounds") + null + } +} diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/stories/Story.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/stories/Story.kt new file mode 100644 index 00000000000..c2d598942fd --- /dev/null +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/stories/Story.kt @@ -0,0 +1,9 @@ +package au.com.shiftyjelly.pocketcasts.endofyear.stories + +import androidx.compose.ui.graphics.Color +import au.com.shiftyjelly.pocketcasts.utils.seconds + +abstract class Story { + val storyLength: Long = 2.seconds() + abstract val backgroundColor: Color +} diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/stories/StoryFake1.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/stories/StoryFake1.kt new file mode 100644 index 00000000000..c4fe4039759 --- /dev/null +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/stories/StoryFake1.kt @@ -0,0 +1,7 @@ +package au.com.shiftyjelly.pocketcasts.endofyear.stories + +import androidx.compose.ui.graphics.Color + +class StoryFake1 : Story() { + override val backgroundColor: Color = Color.Magenta +} diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/stories/StoryFake2.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/stories/StoryFake2.kt new file mode 100644 index 00000000000..f14b242548b --- /dev/null +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/stories/StoryFake2.kt @@ -0,0 +1,7 @@ +package au.com.shiftyjelly.pocketcasts.endofyear.stories + +import androidx.compose.ui.graphics.Color + +class StoryFake2 : Story() { + override val backgroundColor: Color = Color.Green +} diff --git a/modules/features/endofyear/src/test/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesViewModelTest.kt b/modules/features/endofyear/src/test/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesViewModelTest.kt new file mode 100644 index 00000000000..97234b38330 --- /dev/null +++ b/modules/features/endofyear/src/test/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesViewModelTest.kt @@ -0,0 +1,128 @@ +package au.com.shiftyjelly.pocketcasts.endofyear + +import au.com.shiftyjelly.pocketcasts.endofyear.stories.Story +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +private val story1 = mock() +private val story2 = mock() + +@RunWith(MockitoJUnitRunner::class) +class StoriesViewModelTest { + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `when vm starts, then loading is shown`() = runTest { + Dispatchers.setMain(StandardTestDispatcher()) + val viewModel = StoriesViewModel(MockStoriesDataSource(listOf(story1, story2))) + + assertEquals(viewModel.state.value is StoriesViewModel.State.Loading, true) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `when vm starts, then progress is zero`() = runTest { + Dispatchers.setMain(StandardTestDispatcher()) + val viewModel = StoriesViewModel(MockStoriesDataSource(listOf(story1, story2))) + + assertEquals(viewModel.progress.value, 0f) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `when vm starts, then stories are loaded`() = runTest { + val dataSource = mock() + StoriesViewModel(dataSource) + + verify(dataSource).loadStories() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `when stories are found, then progress increments`() = runTest { + val viewModel = StoriesViewModel(MockStoriesDataSource(listOf(story1, story2))) + + val progress = mutableListOf() + val collectJob = launch(UnconfinedTestDispatcher()) { + viewModel.progress.collect { + progress.add(it) + } + } + + assertTrue(progress.last() > 0f) + collectJob.cancel() + } + + @Test + fun `given no stories found, when vm starts, then error is shown`() { + val viewModel = StoriesViewModel(MockStoriesDataSource(emptyList())) + + assertEquals(viewModel.state.value is StoriesViewModel.State.Error, true) + } + + @Test + fun `given stories found, when vm starts, then screen is loaded`() { + val viewModel = StoriesViewModel(MockStoriesDataSource(listOf(story1, story2))) + + assertEquals(viewModel.state.value is StoriesViewModel.State.Loaded, true) + } + + @Test + fun `when next is invoked, then next story is shown`() { + val viewModel = StoriesViewModel(MockStoriesDataSource(listOf(story1, story2))) + + viewModel.skipNext() + + val state = viewModel.state.value as StoriesViewModel.State.Loaded + assertEquals(state.currentStory, story2) + } + + @Test + fun `when previous is invoked, then previous story is shown`() { + val viewModel = StoriesViewModel(MockStoriesDataSource(listOf(story1, story2))) + viewModel.skipNext() + + viewModel.skipPrevious() + + val state = viewModel.state.value as StoriesViewModel.State.Loaded + assertEquals(state.currentStory, story1) + } + + class MockStoriesDataSource(private val mockStories: List) : StoriesDataSource() { + override val stories = mutableListOf() + + override suspend fun loadStories(): Flow> { + stories.addAll(mockStories) + return flowOf(stories) + } + + override fun storyAt(index: Int): Story? { + return try { + stories[index] + } catch (e: IndexOutOfBoundsException) { + null + } + } + } +}