Skip to content

Commit

Permalink
create a HomePresenter for the dashboard home screen
Browse files Browse the repository at this point in the history
  • Loading branch information
frett committed Nov 26, 2024
1 parent cc1ae2c commit 1867ea6
Show file tree
Hide file tree
Showing 3 changed files with 331 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package org.cru.godtools.ui.dashboard.home

import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import com.slack.circuit.codegen.annotations.CircuitInject
import com.slack.circuit.runtime.Navigator
import com.slack.circuit.runtime.presenter.Presenter
import com.slack.circuitx.android.IntentScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import org.cru.godtools.BuildConfig
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_LESSON
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_TOOL
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_TOOL_DETAILS
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_FAVORITE
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_FEATURED
import org.cru.godtools.base.Settings
import org.cru.godtools.db.repository.ToolsRepository
import org.cru.godtools.tutorial.PageSet
import org.cru.godtools.ui.banner.BannerType
import org.cru.godtools.ui.dashboard.home.HomeScreen.UiEvent
import org.cru.godtools.ui.dashboard.home.HomeScreen.UiState
import org.cru.godtools.ui.dashboard.tools.ToolsScreen
import org.cru.godtools.ui.tooldetails.ToolDetailsScreen
import org.cru.godtools.ui.tools.ToolCard
import org.cru.godtools.ui.tools.ToolCardPresenter
import org.cru.godtools.util.createToolIntent
import org.greenrobot.eventbus.EventBus

class HomePresenter @AssistedInject constructor(
@ApplicationContext
private val context: Context,
private val eventBus: EventBus,
private val settings: Settings,
private val toolCardPresenter: ToolCardPresenter,
private val toolsRepository: ToolsRepository,
@Assisted
private val navigator: Navigator,
) : Presenter<UiState> {
@Composable
override fun present(): UiState {
val favoriteTools = rememberFavoriteTools()

return UiState(
banner = rememberBanner(),
spotlightLessons = rememberSpotlightLessons(),
favoriteTools = favoriteTools.orEmpty(),
favoriteToolsLoaded = favoriteTools != null,
) {
when (it) {
UiEvent.ViewAllFavorites -> navigator.goTo(AllFavoritesScreen)
UiEvent.ViewAllTools -> navigator.resetRoot(ToolsScreen, saveState = true, restoreState = true)
}
}
}

@Composable
private fun rememberBanner() = remember {
settings.isFeatureDiscoveredFlow(Settings.FEATURE_TUTORIAL_FEATURES)
.combine(settings.appLanguageFlow) { discovered, language ->
when {
!discovered && PageSet.FEATURES.supportsLocale(language) -> BannerType.TUTORIAL_FEATURES
else -> null
}
}
}.collectAsState(null).value

@Composable
private fun rememberSpotlightLessons() =
remember { toolsRepository.getLessonsFlow().map { it.filter { !it.isHidden && it.isSpotlight } } }
.collectAsState(emptyList()).value
.mapNotNull { lesson ->
val lessonCode = lesson.code ?: return@mapNotNull null

key(lessonCode) {
lateinit var lessonState: ToolCard.State
lessonState = toolCardPresenter.present(lesson) {
when (it) {
ToolCard.Event.Click -> {
val intent = lesson.createToolIntent(
context = context,
languages = listOfNotNull(lessonState.translation?.languageCode),
)

if (intent != null) {
eventBus.post(
OpenAnalyticsActionEvent(ACTION_OPEN_LESSON, lessonCode, SOURCE_FEATURED)
)
navigator.goTo(IntentScreen(intent))
}
}

else -> if (BuildConfig.DEBUG) error("$it is currently unsupported for Lesson Cards")
}
}
lessonState
}
}

@Composable
private fun rememberFavoriteTools() = remember { toolsRepository.getFavoriteToolsFlow().map { it.take(5) } }
.collectAsState(null).value
?.mapNotNull { tool ->
val toolCode = tool.code ?: return@mapNotNull null

key(toolCode) {
lateinit var state: ToolCard.State
state = toolCardPresenter.present(tool) {
when (it) {
ToolCard.Event.Click,
ToolCard.Event.OpenTool -> {
val intent = tool.createToolIntent(
context = context,
languages = listOfNotNull(state.translation?.languageCode),
)

if (intent != null) {
eventBus.post(
OpenAnalyticsActionEvent(ACTION_OPEN_TOOL, tool.code, SOURCE_FAVORITE)
)
navigator.goTo(IntentScreen(intent))
}
}
ToolCard.Event.OpenToolDetails -> {
eventBus.post(
OpenAnalyticsActionEvent(ACTION_OPEN_TOOL_DETAILS, toolCode, SOURCE_FAVORITE)
)
navigator.goTo(ToolDetailsScreen(toolCode))
}
ToolCard.Event.PinTool,
ToolCard.Event.UnpinTool -> error("$it should be handled by the ToolCardPresenter")
}
}
state
}
}

@AssistedFactory
@CircuitInject(HomeScreen::class, SingletonComponent::class)
interface Factory {
fun create(navigator: Navigator): HomePresenter
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import org.cru.godtools.ui.banner.BannerType
import org.cru.godtools.ui.tools.ToolCard

@Parcelize
internal data object HomeScreen : Screen {
data object HomeScreen : Screen {
data class UiState(
val banner: BannerType? = null,
val spotlightLessons: List<ToolCard.State> = emptyList(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package org.cru.godtools.ui.dashboard.home

import android.app.Application
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.jeppeman.mockposable.mockk.everyComposable
import com.slack.circuit.test.FakeNavigator
import com.slack.circuit.test.test
import io.mockk.every
import io.mockk.mockk
import java.util.Locale
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.cru.godtools.base.Settings
import org.cru.godtools.base.Settings.Companion.FEATURE_TUTORIAL_FEATURES
import org.cru.godtools.db.repository.ToolsRepository
import org.cru.godtools.model.Tool
import org.cru.godtools.model.randomTool
import org.cru.godtools.ui.banner.BannerType
import org.cru.godtools.ui.tools.ToolCard
import org.cru.godtools.ui.tools.ToolCardPresenter
import org.greenrobot.eventbus.EventBus
import org.junit.runner.RunWith
import org.robolectric.annotation.Config

@RunWith(AndroidJUnit4::class)
@Config(application = Application::class)
class HomePresenterTest {
private val appLanguageFlow = MutableStateFlow(Locale.ENGLISH)
private val lessonsFlow = MutableStateFlow(emptyList<Tool>())
private val toolsFlow = MutableSharedFlow<List<Tool>>(replay = 1)

private val context: Context = ApplicationProvider.getApplicationContext()
private val eventBus: EventBus = mockk(relaxUnitFun = true)
private val settings: Settings = mockk {
every { appLanguageFlow } returns this@HomePresenterTest.appLanguageFlow
every { isFeatureDiscoveredFlow(any()) } returns flowOf(true)
}
private val toolsRepository: ToolsRepository = mockk {
every { getLessonsFlow() } returns lessonsFlow
every { getFavoriteToolsFlow() } returns toolsFlow
}
private val toolCardPresenter: ToolCardPresenter = mockk {
everyComposable {
present(tool = any(), eventSink = any())
}.answers { ToolCard.State(toolCode = firstArg<Tool>().code, eventSink = arg(4)) }
}

private val navigator = FakeNavigator(HomeScreen)

private val presenter = HomePresenter(
context = context,
eventBus = eventBus,
settings = settings,
toolCardPresenter = toolCardPresenter,
toolsRepository = toolsRepository,
navigator = navigator,
)

// region State.banner
@Test
fun `State - banner - Features Tutorial`() = runTest {
val featuresTutorialDiscovered = MutableStateFlow(true)
every { settings.isFeatureDiscoveredFlow(FEATURE_TUTORIAL_FEATURES) } returns featuresTutorialDiscovered

presenter.test {
assertNull(expectMostRecentItem().banner)

featuresTutorialDiscovered.value = false
assertEquals(BannerType.TUTORIAL_FEATURES, awaitItem().banner)

featuresTutorialDiscovered.value = true
assertNull(awaitItem().banner)
}
}

@Test
fun `State - banner - Features Tutorial - Only visible for supported languages`() = runTest {
every { settings.isFeatureDiscoveredFlow(FEATURE_TUTORIAL_FEATURES) } returns flowOf(false)

presenter.test {
assertEquals(BannerType.TUTORIAL_FEATURES, expectMostRecentItem().banner)

appLanguageFlow.value = Locale.forLanguageTag("x-test")
assertNull(awaitItem().banner)

appLanguageFlow.value = Locale.ENGLISH
assertEquals(BannerType.TUTORIAL_FEATURES, expectMostRecentItem().banner)
}
}
// endregion State.banner

// region State.spotlightLessons
@Test
fun `State - spotlightLessons`() = runTest {
lessonsFlow.value = emptyList()

presenter.test {
assertEquals(emptyList(), expectMostRecentItem().spotlightLessons)

lessonsFlow.value = List(3) { randomTool(type = Tool.Type.LESSON, isHidden = false, isSpotlight = true) }
assertEquals(lessonsFlow.value.map { it.code }, awaitItem().spotlightLessons.map { it.toolCode })

lessonsFlow.value = emptyList()
assertEquals(emptyList(), awaitItem().spotlightLessons)
}
}

@Test
fun `State - spotlightLessons - Only Spotlight Lessons`() = runTest {
lessonsFlow.value = listOf(
randomTool(code = "valid", type = Tool.Type.LESSON, isHidden = false, isSpotlight = true),
randomTool(code = "invalid", type = Tool.Type.LESSON, isHidden = false, isSpotlight = false),
)

presenter.test {
assertEquals(listOf("valid"), expectMostRecentItem().spotlightLessons.map { it.toolCode })
}
}

@Test
fun `State - spotlightLessons - Exclude hidden Lessons`() = runTest {
lessonsFlow.value = listOf(
randomTool(code = "valid", type = Tool.Type.LESSON, isHidden = false, isSpotlight = true),
randomTool(code = "invalid", type = Tool.Type.LESSON, isHidden = true, isSpotlight = true),
)

presenter.test {
assertEquals(listOf("valid"), expectMostRecentItem().spotlightLessons.map { it.toolCode })
}
}
// endregion State.spotlightLessons

// region State.favoriteTools
@Test
fun `State - favoriteTools`() = runTest {
val tools = List(3) { randomTool(type = Tool.Type.TRACT, isHidden = false) }

presenter.test {
toolsFlow.emit(tools)
assertEquals(tools.map { it.code }, expectMostRecentItem().favoriteTools.map { it.toolCode })
}
}

@Test
fun `State - favoriteTools - limit to 5 tools`() = runTest {
val tools = List(10) { randomTool(type = Tool.Type.TRACT, isHidden = false) }

presenter.test {
toolsFlow.emit(tools)
expectMostRecentItem().favoriteTools.let {
assertEquals(5, it.size)
assertEquals(tools.take(5).map { it.code }, it.map { it.toolCode })
}
}
}
// endregion State.favoriteTools

// region State.favoriteToolsLoaded
@Test
fun `State - favoriteToolsLoaded`() = runTest {
presenter.test {
assertFalse(expectMostRecentItem().favoriteToolsLoaded)

toolsFlow.emit(emptyList())
assertTrue(expectMostRecentItem().favoriteToolsLoaded)
}
}
// endregion State.favoriteToolsLoaded
}

0 comments on commit 1867ea6

Please sign in to comment.