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

[Session] 세션 목록 및 상세 북마크 기능 #173

Merged
merged 11 commits into from
Aug 13, 2023
1 change: 1 addition & 0 deletions core/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ dependencies {
implementation(libs.okhttp.logging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
testImplementation(libs.turbine)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@ package com.droidknights.app2023.core.data.repository
import com.droidknights.app2023.core.data.api.GithubRawApi
import com.droidknights.app2023.core.data.mapper.toData
import com.droidknights.app2023.core.model.Session
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.update
import javax.inject.Inject

internal class DefaultSessionRepository @Inject constructor(
private val githubRawApi: GithubRawApi,
) : SessionRepository {
private var cachedSessions: List<Session> = emptyList()

/**
* TODO : 북마크 아이디가 앱이 종료된 이후에도 유지되도록 한다
*/
private val bookmarkIds: MutableStateFlow<Set<String>> = MutableStateFlow(emptySet())

override suspend fun getSessions(): List<Session> {
return githubRawApi.getSessions()
.map { it.toData() }
Expand All @@ -24,4 +33,18 @@ internal class DefaultSessionRepository @Inject constructor(
return getSessions().find { it.id == sessionId }
?: throw IllegalStateException("Session not found with id: $sessionId")
}

override suspend fun getBookmarkedSessionIds(): Flow<Set<String>> {
return bookmarkIds.filterNotNull()
}

override suspend fun bookmarkSession(sessionId: String, bookmark: Boolean) {
bookmarkIds.update { ids ->
if (bookmark) {
ids + sessionId
} else {
ids - sessionId
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package com.droidknights.app2023.core.data.repository

import com.droidknights.app2023.core.model.Session
import kotlinx.coroutines.flow.Flow

interface SessionRepository {

suspend fun getSessions(): List<Session>

suspend fun getSession(sessionId: String): Session

suspend fun getBookmarkedSessionIds(): Flow<Set<String>>

suspend fun bookmarkSession(sessionId: String, bookmark: Boolean)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.droidknights.app2023.core.data.repository

import app.cash.turbine.test
import com.droidknights.app2023.core.data.api.fake.FakeGithubRawApi
import com.droidknights.app2023.core.model.Level
import com.droidknights.app2023.core.model.Room
Expand Down Expand Up @@ -52,5 +53,37 @@ internal class DefaultSessionRepositoryTest : StringSpec() {
val actual = repository.getSessions()
actual shouldBe expected
}

"북마크 추가 테스트" {
repository.getBookmarkedSessionIds().test {
awaitItem() shouldBe emptySet()

repository.bookmarkSession(sessionId = "1", bookmark = true)
awaitItem() shouldBe setOf("1")

repository.bookmarkSession(sessionId = "2", bookmark = true)
awaitItem() shouldBe setOf("1", "2")
}
}

"북마크 제거 테스트" {
// given : [1, 2, 3]
val bookmarkedSessionIds = listOf("1", "2", "3")
bookmarkedSessionIds.forEach {
repository.bookmarkSession(it, true)
}

repository.getBookmarkedSessionIds().test {
awaitItem() shouldBe setOf("1", "2", "3")

// [1, 2, 3] -> [1, 3]
repository.bookmarkSession(sessionId = "2", bookmark = false)
awaitItem() shouldBe setOf("1", "3")

// [1, 3] -> [1]
repository.bookmarkSession(sessionId = "3", bookmark = false)
awaitItem() shouldBe setOf("1")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.droidknights.app2023.core.domain.usecase

import com.droidknights.app2023.core.data.repository.SessionRepository
import javax.inject.Inject

class BookmarkSessionUseCase @Inject constructor(
private val sessionRepository: SessionRepository,
) {

suspend operator fun invoke(sessionId: String, bookmark: Boolean) {
return sessionRepository.bookmarkSession(sessionId, bookmark)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.droidknights.app2023.core.domain.usecase

import com.droidknights.app2023.core.data.repository.SessionRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject

class GetBookmarkedSessionIdsUseCase @Inject constructor(
private val sessionRepository: SessionRepository,
) {

suspend operator fun invoke(): Flow<Set<String>> {
return sessionRepository.getBookmarkedSessionIds()
}
}
2 changes: 1 addition & 1 deletion core/testing/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ dependencies {
api(libs.junit.vintage.engine)
api(libs.kotlin.test)
api(libs.mockk)
api(libs.turbin)
api(libs.turbine)
api(libs.coroutines.test)
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ private fun SessionDetailTopAppBar(
navigationType = TopAppBarNavigationType.Back,
onNavigationClick = onBackClick,
)

// TODO: 북마크 확인 및 변경 기능 추가
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ sealed interface SessionDetailUiState {

object Loading : SessionDetailUiState

data class Success(val session: Session) : SessionDetailUiState
data class Success(val session: Session, val bookmarked: Boolean = false) : SessionDetailUiState
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,58 @@ package com.droidknights.app2023.feature.session

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.droidknights.app2023.core.domain.usecase.BookmarkSessionUseCase
import com.droidknights.app2023.core.domain.usecase.GetBookmarkedSessionIdsUseCase
import com.droidknights.app2023.core.domain.usecase.GetSessionUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class SessionDetailViewModel @Inject constructor(
private val getSessionUseCase: GetSessionUseCase,
private val getBookmarkedSessionIdsUseCase: GetBookmarkedSessionIdsUseCase,
private val bookmarkSessionUseCase: BookmarkSessionUseCase,
) : ViewModel() {

private val _sessionUiState =
MutableStateFlow<SessionDetailUiState>(SessionDetailUiState.Loading)
val sessionUiState: StateFlow<SessionDetailUiState> = _sessionUiState

init {
viewModelScope.launch {
combine(
sessionUiState,
getBookmarkedSessionIdsUseCase(),
) { sessionUiState, bookmarkIds ->
when (sessionUiState) {
is SessionDetailUiState.Loading -> sessionUiState
is SessionDetailUiState.Success -> {
sessionUiState.copy(bookmarked = bookmarkIds.contains(sessionUiState.session.id))
}
}
}.collect { _sessionUiState.value = it }
}
}

fun fetchSession(sessionId: String) {
viewModelScope.launch {
val session = getSessionUseCase(sessionId)
_sessionUiState.value = SessionDetailUiState.Success(session)
}
}

fun toggleBookmark() {
val uiState = sessionUiState.value
if (uiState !is SessionDetailUiState.Success) {
return
}
viewModelScope.launch {
val bookmark = uiState.bookmarked
bookmarkSessionUseCase(uiState.session.id, !bookmark)
}
}
}
11 changes: 11 additions & 0 deletions feature/session/src/main/res/drawable/ic_session_bookmark.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#00000000"
android:pathData="M24,29.294L18,33V15H30V33L24,29.294Z"
android:strokeWidth="1.7"
android:strokeColor="#C2C2C2" />
</vector>
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
package com.droidknights.app2023.feature.session

import app.cash.turbine.test
import com.droidknights.app2023.core.domain.usecase.BookmarkSessionUseCase
import com.droidknights.app2023.core.domain.usecase.GetBookmarkedSessionIdsUseCase
import com.droidknights.app2023.core.domain.usecase.GetSessionUseCase
import com.droidknights.app2023.core.model.Level
import com.droidknights.app2023.core.model.Room
import com.droidknights.app2023.core.model.Session
import com.droidknights.app2023.core.testing.rule.MainDispatcherRule
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.LocalDateTime
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertIs
import kotlin.test.assertTrue

class SessionDetailViewModelTest {
@get:Rule
val dispatcherRule = MainDispatcherRule()

private val getSessionUseCase: GetSessionUseCase = mockk()
private val getBookmarkedSessionIdsUseCase: GetBookmarkedSessionIdsUseCase = mockk()
private val bookmarkSessionUseCase: BookmarkSessionUseCase = mockk()
private lateinit var viewModel: SessionDetailViewModel

private val fakeSession = Session(
Expand All @@ -33,12 +42,21 @@ class SessionDetailViewModelTest {
endTime = LocalDateTime(2023, 9, 12, 13, 30, 0),
)

@Before
fun setup() {
coEvery { getBookmarkedSessionIdsUseCase() } returns flowOf(emptySet())
}

@Test
fun `세션 데이터를 확인할 수 있다`() = runTest {
// given
val sessionId = "1"
coEvery { getSessionUseCase(sessionId) } returns fakeSession
viewModel = SessionDetailViewModel(getSessionUseCase)
viewModel = SessionDetailViewModel(
getSessionUseCase,
getBookmarkedSessionIdsUseCase,
bookmarkSessionUseCase
)

// when
viewModel.fetchSession(sessionId)
Expand All @@ -49,4 +67,55 @@ class SessionDetailViewModelTest {
assertIs<SessionDetailUiState.Success>(actual)
}
}

@Test
fun `세션의 북마크 여부를 확인할 수 있다`() = runTest {
// given
val sessionId = "1"
coEvery { getSessionUseCase(sessionId) } returns fakeSession
coEvery { getBookmarkedSessionIdsUseCase() } returns flowOf(setOf(sessionId))
viewModel = SessionDetailViewModel(
getSessionUseCase,
getBookmarkedSessionIdsUseCase,
bookmarkSessionUseCase
)

// when
viewModel.fetchSession(sessionId)

// then
viewModel.sessionUiState.test {
val actual = awaitItem() as SessionDetailUiState.Success
assertTrue(actual.bookmarked)
}
}

@Test
fun `세션의 북마크 여부를 변경할 수 있다`() = runTest {
// given
val sessionId = "1"
coEvery { getSessionUseCase(sessionId) } returns fakeSession

val flow = MutableStateFlow(emptySet<String>())
coEvery { getBookmarkedSessionIdsUseCase() } returns flow
coEvery { bookmarkSessionUseCase(sessionId, true) } answers {
flow.update { it + sessionId }
}

viewModel = SessionDetailViewModel(
getSessionUseCase,
getBookmarkedSessionIdsUseCase,
bookmarkSessionUseCase
)
viewModel.fetchSession(sessionId)

// when
viewModel.toggleBookmark()

// then
viewModel.sessionUiState.test {
val actual = awaitItem() as SessionDetailUiState.Success
assertTrue(actual.bookmarked)
}
}
}
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ kotest = "5.6.2"
# https://github.com/detekt/detekt
detekt = "1.23.0"
mockk = "1.13.5"
turbin = "1.0.0"
turbine = "1.0.0"

coroutine = "1.7.2"

Expand Down Expand Up @@ -84,7 +84,7 @@ kotest-runner = { group = "io.kotest", name = "kotest-runner-junit5", version.re
kotest-assertions = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
turbin = { group = "app.cash.turbine", name = "turbine", version.ref = "turbin" }
turbin = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }

coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" }
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutine" }
Expand Down