Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Navigate to Article List screen from Detail screen #147

Open
wants to merge 3 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/android_unit_test_action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Slime Android Unit Tests

on: push

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v1

- name: Permission for Gradle Execution
run: chmod +x gradlew

- name: Slime Unit Tests Check
run: ./gradlew test
2 changes: 1 addition & 1 deletion app/src/main/java/kasem/sm/slime/ui/navigation/NavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ fun NavHost(
attachHomeScreen(imageLoader, navController, snackbarHostState)
attachExploreScreen(navController, imageLoader, snackbarHostState)
attachProfileScreen(navController)
attachArticleDetailScreen(imageLoader, snackbarHostState)
attachArticleDetailScreen(imageLoader, snackbarHostState, navController)
attachSelectTopicsScreen(navController, snackbarHostState)
attachListScreen(imageLoader, snackbarHostState, navController)
attachBookmarksScreen(imageLoader, navController)
Expand Down
6 changes: 5 additions & 1 deletion app/src/main/java/kasem/sm/slime/ui/navigation/Screens.kt
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ fun NavGraphBuilder.attachProfileScreen(
fun NavGraphBuilder.attachArticleDetailScreen(
imageLoader: ImageLoader,
snackbarHostState: SnackbarHostState,
navController: NavController
) {
composable(
route = Destination.ArticleDetailScreen.route,
Expand All @@ -146,7 +147,10 @@ fun NavGraphBuilder.attachArticleDetailScreen(
DetailScreen(
imageLoader = imageLoader,
viewModel = hiltViewModel(),
snackbarHostState = snackbarHostState
snackbarHostState = snackbarHostState,
onTopicClick = { title, id ->
navController.navigate(Destination.ListScreen(title, id).route)
}
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.Flow
class ObserveAuthState @Inject constructor(
private val authManager: AuthManager
) : ObserverInteractor<Unit, AuthState>() {
override suspend fun execute(params: Unit): Flow<AuthState> {
override fun execute(params: Unit): Flow<AuthState> {
return authManager.state
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,7 @@ interface TopicDatabaseService {

fun getTopicById(id: String): Flow<TopicEntity?>

fun getTopicByTitle(title: String): Flow<TopicEntity?>

suspend fun updateSubscriptionStatus(status: Boolean, id: String? = null)
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ internal class TopicDatabaseServiceImpl @Inject constructor(
}
}

override fun getTopicByTitle(title: String): Flow<TopicEntity?> {
return slimeTry {
dao.getTopicByTitle(title)
}
}

override suspend fun updateSubscriptionStatus(status: Boolean, id: String?) {
return slimeSuspendTry {
if (id != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ interface TopicDao {
@Query("SELECT * FROM table_topic WHERE topic_id = :id")
fun getTopicById(id: String): Flow<TopicEntity?>

@Query("SELECT * FROM table_topic WHERE topic_title = :title")
fun getTopicByTitle(title: String): Flow<TopicEntity?>

@Query("UPDATE table_topic SET is_in_subscription = :inSubscription, is_in_explore = :inExplore WHERE topic_id = :id")
suspend fun updateSubscriptionStatus(
inSubscription: Boolean,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright (C) 2022, Kasem S.M
* All rights reserved.
*/
package kasem.sm.topic.domain.observers

import javax.inject.Inject
import kasem.sm.core.domain.ObserverInteractor
import kasem.sm.topic.datasource.cache.TopicDatabaseService
import kasem.sm.topic.domain.interactors.toDomain
import kasem.sm.topic.domain.model.Topic
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

class ObserveTopicByTitle @Inject constructor(
private val cache: TopicDatabaseService,
) : ObserverInteractor<String, Topic?>() {
override fun execute(params: String): Flow<Topic?> {
return cache.getTopicByTitle(params).map { it.toDomain() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ package kasem.sm.topic.domain.model
data class Topic(
val id: String,
val title: String,
val timestamp: Long,
val timestamp: Long = -1,
val isSelected: Boolean = false,
val totalSubscribers: Int,
val hasUserSubscribed: Boolean,
val totalSubscribers: Int = 0,
val hasUserSubscribed: Boolean = false,
)
3 changes: 3 additions & 0 deletions screen/ui-article-detail/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ dependencies {
implementation(project(":features:article:markdown"))
implementation(project(":features:article:dynamic-links-handler"))

implementation(project(":features:topic:domain:model"))
implementation(project(":features:topic:domain:interactors"))

implementation Coil.core
implementation Compose.iconsExtended

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import kasem.sm.common_ui.R.string
import kasem.sm.common_ui.SlimeScreenColumn
import kasem.sm.common_ui.SlimeSwipeRefresh
import kasem.sm.common_ui.TopicChip
import kasem.sm.common_ui.util.clickWithRipple
import kasem.sm.dynamic_links_handler.SLIME_DYNAMIC_LINK
import kasem.sm.dynamic_links_handler.generateSharingLink
import kasem.sm.ui_detail.components.ArticleAuthorAndEstimatedTimeBadge
Expand All @@ -41,6 +42,7 @@ internal fun DetailContent(
state: DetailState,
snackbarHostState: SnackbarHostState,
onRefresh: () -> Unit,
onTopicClick: (title: String, id: String) -> Unit,
) {
SlimeSwipeRefresh(
refreshing = state.isLoading,
Expand Down Expand Up @@ -70,7 +72,13 @@ internal fun DetailContent(
}

item {
TopicChip(topic = article.topic)
TopicChip(
topic = article.topic,
modifier = Modifier
.clickWithRipple {
state.topic?.let { onTopicClick(it.title, it.id) }
}
)
}

item {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import kasem.sm.ui_core.safeCollector
fun DetailScreen(
viewModel: DetailVM,
imageLoader: ImageLoader,
snackbarHostState: SnackbarHostState
snackbarHostState: SnackbarHostState,
onTopicClick: (title: String, id: String) -> Unit,
) {
val state by rememberStateWithLifecycle(viewModel.state)

Expand All @@ -28,5 +29,6 @@ fun DetailScreen(
state = state,
snackbarHostState = snackbarHostState,
onRefresh = viewModel::refresh,
onTopicClick = onTopicClick
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ package kasem.sm.ui_detail

import androidx.compose.runtime.Immutable
import kasem.sm.article.domain.model.Article
import kasem.sm.topic.domain.model.Topic

@Immutable
data class DetailState(
val isLoading: Boolean = true,
val article: Article? = null,
val topic: Topic? = null
) {
companion object {
val EMPTY = DetailState()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import kasem.sm.article.domain.observers.ObserveArticle
import kasem.sm.core.domain.ObservableLoader
import kasem.sm.core.domain.SlimeDispatchers
import kasem.sm.core.domain.collect
import kasem.sm.topic.domain.observers.ObserveTopicByTitle
import kasem.sm.ui_core.UiEvent
import kasem.sm.ui_core.showMessage
import kasem.sm.ui_core.stateIn
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
Expand All @@ -28,6 +30,7 @@ import kotlinx.coroutines.plus
class DetailVM @Inject constructor(
private val getArticle: GetArticle,
private val observeArticle: ObserveArticle,
private val observeTopic: ObserveTopicByTitle,
private val dispatchers: SlimeDispatchers,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
Expand All @@ -41,11 +44,13 @@ class DetailVM @Inject constructor(

val state: StateFlow<DetailState> = combine(
loadingStatus.flow,
observeArticle.flow
) { loading, article ->
observeArticle.flow,
observeTopic.flow,
) { loading, article, topic ->
DetailState(
isLoading = loading,
article = article
article = article,
topic = topic
)
}.stateIn(
coroutineScope = viewModelScope + dispatchers.main,
Expand All @@ -59,13 +64,23 @@ class DetailVM @Inject constructor(
}

private fun observe() {
observeArticle.join(
coroutineScope = viewModelScope + dispatchers.main,
onError = {
_uiEvent.emit(showMessage(it))
},
params = articleId,
)
viewModelScope.launch(dispatchers.main) {
observeArticle.joinAndCollect(
coroutineScope = viewModelScope + dispatchers.main,
onError = {
_uiEvent.emit(showMessage(it))
},
params = articleId,
).collectLatest { article ->
article?.let {
observeTopic.join(
params = it.topic,
coroutineScope = viewModelScope + dispatchers.main,
onError = { msg -> _uiEvent.emit(showMessage(msg)) },
)
}
}
}
}

fun refresh() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,92 +4,4 @@
*/
package kasem.sm.ui_detail

import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import io.mockk.coEvery
import io.mockk.mockk
import java.io.IOException
import kasem.sm.article.domain.interactors.GetArticle
import kasem.sm.article.domain.model.Article
import kasem.sm.article.domain.observers.ObserveArticle
import kasem.sm.common_test_utils.ThreadExceptionTestRule
import kasem.sm.common_test_utils.shouldBe
import kasem.sm.core.domain.SlimeDispatchers
import kasem.sm.core.domain.Stage
import kasem.sm.ui_core.showMessage
import kasem.sm.ui_detail.utils.ArticleFakes.getMockDomain
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test

class DetailVMTest {
@get:Rule
val uncaughtExceptionHandler = ThreadExceptionTestRule()

private lateinit var viewModel: DetailVM

private val getArticle: GetArticle = mockk(relaxed = true)

private var observeArticle: ObserveArticle = mockk(relaxUnitFun = true)

private fun initViewModel(article: Article? = null) {
coEvery { observeArticle.flow } returns flow {
article?.let { emit(it) }
}

viewModel = DetailVM(
getArticle = getArticle,
observeArticle = observeArticle,
dispatchers = SlimeDispatchers.createTestDispatchers(UnconfinedTestDispatcher()),
savedStateHandle = SavedStateHandle()
)
}

@Test
fun testStateEmits_ProperData() = runTest {
initViewModel(getMockDomain())

viewModel.state.test {
val state = awaitItem()
state shouldBe DetailState(
isLoading = false,
article = getMockDomain()
)
cancelAndIgnoreRemainingEvents()
}
}

@Test
fun testArticleIsNull_When_CacheThrowsException() = runTest {
initViewModel()

coEvery { observeArticle.flow } throws IOException()

viewModel.state.test {
val state = awaitItem()
state shouldBe DetailState(
isLoading = true,
article = null
)
cancelAndIgnoreRemainingEvents()
}
}

@Test
fun testUiEventEmit_ProperError() = runTest {
initViewModel()

coEvery { getArticle.execute(any()) } returns flow {
emit(Stage.Exception(IOException()))
}

viewModel.uiEvent.test {
viewModel.refresh()
val event = awaitItem()
event shouldBe showMessage("Something went wrong!")
cancelAndIgnoreRemainingEvents()
}
}
}
class DetailVMTest
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ class CoroutinesTestRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {

override fun starting(description: Description?) {
override fun starting(description: Description) {
super.starting(description)
Dispatchers.setMain(testDispatcher)
}

override fun finished(description: Description?) {
override fun finished(description: Description) {
super.finished(description)
Dispatchers.resetMain()
}
Expand Down