Skip to content

Commit

Permalink
Merge pull request #472 from nimblehq/release/3.20.0
Browse files Browse the repository at this point in the history
[Release] 3.20.0
  • Loading branch information
ryan-conway authored Jun 1, 2023
2 parents 631991d + 31e1269 commit e552114
Show file tree
Hide file tree
Showing 25 changed files with 381 additions and 117 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/publish_docs_to_wiki.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out the repository
uses: actions/checkout@v3
with:
ref: ${{ github.head_ref }}

- name: Publish Github Wiki
uses: nimblehq/[email protected]
with:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ abstract class BaseViewModel : ViewModel() {
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<IsLoading> = _isLoading

protected val _error = MutableStateFlow<Throwable?>(null)
val error: StateFlow<Throwable?> = _error
protected val _error = MutableSharedFlow<Throwable>()
val error: SharedFlow<Throwable> = _error

protected val _navigator = MutableSharedFlow<AppDestination>()
val navigator: SharedFlow<AppDestination> = _navigator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,14 @@ fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
navigator: (destination: AppDestination) -> Unit,
) {
val context = LocalContext.current
viewModel.error.collectAsEffect { e -> e.showToast(context) }
viewModel.navigator.collectAsEffect { destination -> navigator(destination) }

val isLoading: IsLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val uiModels: List<UiModel> by viewModel.uiModels.collectAsStateWithLifecycle()
val isFirstTimeLaunch: Boolean by viewModel.isFirstTimeLaunch.collectAsStateWithLifecycle()

val context = LocalContext.current
val error: Throwable? by viewModel.error.collectAsStateWithLifecycle()
error?.showToast(context)
LaunchedEffect(isFirstTimeLaunch) {
if (isFirstTimeLaunch) {
context.showToast("This is the first time launch")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import org.junit.runner.Description

@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineTestRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
var testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {

val testDispatcherProvider = object : DispatchersProvider {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package co.nimblehq.sample.compose.ui.screens

import co.nimblehq.sample.compose.test.CoroutineTestRule
import kotlinx.coroutines.test.StandardTestDispatcher

abstract class BaseScreenTest {

protected val coroutinesRule = CoroutineTestRule()

protected fun setStandardTestDispatcher() {
coroutinesRule.testDispatcher = StandardTestDispatcher()
}

protected fun advanceUntilIdle() {
coroutinesRule.testDispatcher.scheduler.advanceUntilIdle()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import androidx.test.rule.GrantPermissionRule
import co.nimblehq.sample.compose.R
import co.nimblehq.sample.compose.domain.model.Model
import co.nimblehq.sample.compose.domain.usecase.*
import co.nimblehq.sample.compose.test.CoroutineTestRule
import co.nimblehq.sample.compose.ui.AppDestination
import co.nimblehq.sample.compose.ui.screens.BaseScreenTest
import co.nimblehq.sample.compose.ui.screens.MainActivity
import co.nimblehq.sample.compose.ui.theme.ComposeTheme
import io.kotest.matchers.shouldBe
Expand All @@ -26,9 +26,7 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.shadows.ShadowToast

@RunWith(RobolectricTestRunner::class)
class HomeScreenTest {

private val coroutinesRule = CoroutineTestRule()
class HomeScreenTest : BaseScreenTest() {

@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
Expand All @@ -42,8 +40,10 @@ class HomeScreenTest {
)

private val mockGetModelsUseCase: GetModelsUseCase = mockk()
private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = mockk()
private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = mockk()
private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase =
mockk()
private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase =
mockk()

private lateinit var viewModel: HomeViewModel
private var expectedAppDestination: AppDestination? = null
Expand All @@ -54,8 +54,6 @@ class HomeScreenTest {
listOf(Model(1), Model(2), Model(3))
)
every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(false)

initViewModel()
}

@Test
Expand All @@ -70,12 +68,14 @@ class HomeScreenTest {

@Test
fun `When entering the Home screen and loading the list item failure, it shows the corresponding error`() {
setStandardTestDispatcher()

val error = Exception()
every { mockGetModelsUseCase() } returns flow { throw error }
initViewModel()

initComposable {
onNodeWithText("Home").assertIsDisplayed()
composeRule.waitForIdle()
advanceUntilIdle()

ShadowToast.showedToast(activity.getString(R.string.error_generic)) shouldBe true
}
Expand All @@ -89,13 +89,15 @@ class HomeScreenTest {
}

private fun initComposable(
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit,
) {
initViewModel()

composeRule.activity.setContent {
ComposeTheme {
HomeScreen(
viewModel = viewModel,
navigator = { destination -> expectedAppDestination = destination }
navigator = { destination -> expectedAppDestination = destination },
)
}
}
Expand Down
26 changes: 13 additions & 13 deletions sample-compose/buildSrc/src/main/java/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,28 @@ object Versions {
const val ANDROID_TARGET_SDK_VERSION = 33

const val ANDROID_VERSION_CODE = 1
const val ANDROID_VERSION_NAME = "3.19.0"
const val ANDROID_VERSION_NAME = "3.20.0"

// Dependencies (Alphabet sorted)
const val ACCOMPANIST_PERMISSIONS_VERSION = "0.28.0"
const val ACCOMPANIST_PERMISSIONS_VERSION = "0.30.1"
const val ANDROID_COMMON_KTX_VERSION = "0.1.1"
const val ANDROID_CRYPTO_VERSION = "1.0.0"
const val ANDROIDX_CORE_KTX_VERSION = "1.9.0"
const val ANDROIDX_CORE_KTX_VERSION = "1.10.1"
const val ANDROIDX_DATASTORE_PREFERENCES_VERSION = "1.0.0"
const val ANDROIDX_LIFECYCLE_VERSION = "2.6.0-rc01"
const val ANDROIDX_TEST_CORE_VERSION = "1.4.0"
const val ANDROIDX_LIFECYCLE_VERSION = "2.6.1"

const val CHUCKER_VERSION = "3.5.2"
const val COMPOSE_BOM_VERSION = "2022.12.00"
const val COMPOSE_COMPILER_VERSION = "1.4.3"
const val COMPOSE_BOM_VERSION = "2023.04.01"
const val COMPOSE_COMPILER_VERSION = "1.4.7"
const val COMPOSE_NAVIGATION_VERSION = "2.5.3"

const val HILT_VERSION = "2.44"
const val HILT_NAVIGATION_COMPOSE_VERSION = "1.0.0"

const val JAVAX_INJECT_VERSION = "1"

const val KOTLIN_VERSION = "1.8.10"
const val KOTLINX_COROUTINES_VERSION = "1.6.4"
const val KOTLIN_VERSION = "1.8.21"
const val KOTLINX_COROUTINES_VERSION = "1.7.1"
const val KOVER_VERSION = "0.6.0"

const val MOSHI_VERSION = "1.12.0"
Expand All @@ -43,10 +42,11 @@ object Versions {
const val DETEKT_VERSION = "1.21.0"

// Testing libraries
const val TEST_ANDROIDX_CORE_VERSION = "1.4.0"
const val TEST_JUNIT_VERSION = "4.13.2"
const val TEST_KOTEST_VERSION = "5.5.4"
const val TEST_MOCKK_VERSION = "1.12.3"
const val TEST_ROBOLECTRIC_VERSION = "4.9.2"
const val TEST_KOTEST_VERSION = "5.6.2"
const val TEST_MOCKK_VERSION = "1.13.5"
const val TEST_ROBOLECTRIC_VERSION = "4.10.2"
const val TEST_RULES_VERSION = "1.5.0"
const val TEST_TURBINE_VERSION = "0.12.1"
const val TEST_TURBINE_VERSION = "0.13.0"
}
3 changes: 1 addition & 2 deletions sample-compose/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ android {
minSdk = Versions.ANDROID_MIN_SDK_VERSION
targetSdk = Versions.ANDROID_TARGET_SDK_VERSION

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}

Expand Down Expand Up @@ -81,7 +80,7 @@ dependencies {
testImplementation("io.mockk:mockk:${Versions.TEST_MOCKK_VERSION}")
testImplementation("io.kotest:kotest-assertions-core:${Versions.TEST_KOTEST_VERSION}")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.KOTLINX_COROUTINES_VERSION}")
testImplementation("androidx.test:core:${Versions.ANDROIDX_TEST_CORE_VERSION}")
testImplementation("androidx.test:core:${Versions.TEST_ANDROIDX_CORE_VERSION}")
testImplementation("org.robolectric:robolectric:${Versions.TEST_ROBOLECTRIC_VERSION}")
testImplementation("app.cash.turbine:turbine:${Versions.TEST_TURBINE_VERSION}")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package co.nimblehq.sample.xml.extension

import androidx.annotation.MainThread
import androidx.fragment.app.Fragment
import androidx.navigation.NavArgs
import androidx.navigation.fragment.navArgs

@MainThread
inline fun <reified Args : NavArgs> Fragment.provideNavArgs(): Lazy<Args> =
OverridableLazy(navArgs())
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package co.nimblehq.sample.xml.ui.screens.second
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.navArgs
import co.nimblehq.sample.xml.R
import co.nimblehq.sample.xml.databinding.FragmentSecondBinding
import co.nimblehq.sample.xml.extension.provideNavArgs
import co.nimblehq.sample.xml.extension.provideViewModels
import co.nimblehq.sample.xml.ui.base.BaseFragment
import dagger.hilt.android.AndroidEntryPoint
Expand All @@ -14,7 +14,7 @@ import dagger.hilt.android.AndroidEntryPoint
class SecondFragment : BaseFragment<FragmentSecondBinding>() {

private val viewModel: SecondViewModel by provideViewModels()
private val args: SecondFragmentArgs by navArgs()
private val args: SecondFragmentArgs by provideNavArgs()

override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentSecondBinding
get() = { inflater, container, attachToParent ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package co.nimblehq.sample.xml.test

import androidx.navigation.NavArgs
import co.nimblehq.sample.xml.extension.OverridableLazy
import kotlin.reflect.KProperty1
import kotlin.reflect.jvm.isAccessible

fun <Arg : NavArgs, T> T.replace(
argumentDelegate: KProperty1<T, Arg>,
argument: Arg
) {
argumentDelegate.isAccessible = true
(argumentDelegate.getDelegate(this) as OverridableLazy<Arg>).implementation = lazy { argument }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package co.nimblehq.sample.xml.ui.screens.home

import androidx.core.view.isVisible
import co.nimblehq.sample.xml.databinding.FragmentHomeBinding
import co.nimblehq.sample.xml.model.UiModel
import co.nimblehq.sample.xml.test.TestNavigatorModule.mockMainNavigator
import co.nimblehq.sample.xml.test.getPrivateProperty
import co.nimblehq.sample.xml.test.replace
import co.nimblehq.sample.xml.ui.BaseFragmentTest
import co.nimblehq.sample.xml.ui.base.NavigationEvent
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.*
import org.robolectric.shadows.ShadowToast
import java.util.*

@HiltAndroidTest
class HomeFragmentTest : BaseFragmentTest<HomeFragment, FragmentHomeBinding>() {

private val mockViewModel = mockk<HomeViewModel>(relaxed = true)

@get:Rule
var hiltRule = HiltAndroidRule(this)

@Before
fun setUp() {
hiltRule.inject()
}

@Test
fun `When launching fragment, it displays the recycler view`() {
launchFragment()
fragment.binding.rvHome.isVisible.shouldBeTrue()
}

@Test
fun `When launching fragment and view model emits loading, it displays the progress bar`() {
every { mockViewModel.isLoading } returns MutableStateFlow(true)

launchFragment()
fragment.binding.pbHome.isVisible.shouldBeTrue()
}

@Test
fun `When launching fragment and view model does not emit loading, it does not display the progress bar`() {
every { mockViewModel.isLoading } returns MutableStateFlow(false)

launchFragment()
fragment.binding.pbHome.isVisible.shouldBeFalse()
}

@Test
fun `When launching fragment and view model emits list of item, it displays the recycler view with items`() {
val items = arrayListOf(
UiModel(UUID.randomUUID().toString()),
UiModel(UUID.randomUUID().toString()),
UiModel(UUID.randomUUID().toString())
)
every { mockViewModel.uiModels } returns MutableStateFlow(items)

launchFragment()
fragment.binding.rvHome.adapter?.itemCount shouldBe items.size
}

@Test
fun `When launching fragment and view model emits first time launch, it displays a toast message`() {
every { mockViewModel.isFirstTimeLaunch } returns MutableStateFlow(true)

launchFragment()
ShadowToast.getTextOfLatestToast() shouldBe "This is the first time launch"
}

@Test
fun `When launching fragment and view model does not emit first time launch, it does not display a toast message`() {
every { mockViewModel.isFirstTimeLaunch } returns MutableStateFlow(false)

launchFragment()
ShadowToast.getTextOfLatestToast() shouldBe null
}

@Test
fun `When view model emits navigation event to second fragment, it should navigate to second screen`() {
val uiModel = UiModel(UUID.randomUUID().toString())
every { mockViewModel.navigator } returns MutableStateFlow(NavigationEvent.Second(uiModel))
every { mockMainNavigator.navigate(any()) } returns Unit

launchFragment()
verify { mockMainNavigator.navigate(NavigationEvent.Second(uiModel)) }
}

private fun launchFragment() {
launchFragmentInHiltContainer<HomeFragment>(
onInstantiate = {
replace(getPrivateProperty("viewModel"), mockViewModel)
navigator = mockMainNavigator
}
) {
fragment = this
fragment.navigator.shouldNotBeNull()
}
}
}
Loading

0 comments on commit e552114

Please sign in to comment.