Skip to content

[#102] [Integrate] As a user, I can see dialog when there is no internet connection #105

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

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
41 changes: 41 additions & 0 deletions app/src/main/java/co/nimblehq/compose/crypto/CryptoAppState.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package co.nimblehq.compose.crypto

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import co.nimblehq.compose.crypto.domain.usecase.IsNetworkConnectedUseCase
import co.nimblehq.compose.crypto.util.DispatchersProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*

@Composable
fun rememberCryptoAppState(
isNetworkConnectedUseCase: IsNetworkConnectedUseCase,
dispatchersProvider: DispatchersProvider,
) = remember(isNetworkConnectedUseCase, dispatchersProvider) {
CryptoAppState(isNetworkConnectedUseCase, dispatchersProvider)
}

class CryptoAppState(
isNetworkConnectedUseCase: IsNetworkConnectedUseCase,
dispatchersProvider: DispatchersProvider,
) {
private val _isNetworkConnected = MutableSharedFlow<Boolean?>()
val isNetworkConnected: SharedFlow<Boolean?>
get() = _isNetworkConnected

private val _networkError = MutableSharedFlow<Throwable?>()
val networkError: SharedFlow<Throwable?>
get() = _networkError

init {
isNetworkConnectedUseCase()
.catch {
_networkError.emit(it)
}
.onEach {
_isNetworkConnected.emit(it)
}
.flowOn(dispatchersProvider.io)
.launchIn(CoroutineScope(dispatchersProvider.io))
}
}
19 changes: 19 additions & 0 deletions app/src/main/java/co/nimblehq/compose/crypto/extension/FlowExt.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package co.nimblehq.compose.crypto.extension

import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.flow.*
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext

@SuppressLint("ComposableNaming")
@Composable
fun <T> Flow<T>.collectAsEffect(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend (T) -> Unit,
) {
LaunchedEffect(key1 = Unit) {
onEach(block).flowOn(context).launchIn(this)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ sealed class AppDestination(val route: String = "") {

object Home : AppDestination("home")

object NoNetwork : AppDestination("no_network")

/**
* We can define route as "coin/details" without "coinId" parameter because we're passing it as argument already.
* So either passing "coinId" via arguments or passing it via route.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
package co.nimblehq.compose.crypto.ui.navigation

import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.navigation.*
import androidx.navigation.compose.*
import co.nimblehq.compose.crypto.R
import co.nimblehq.compose.crypto.ui.common.AppDialogPopUp
import co.nimblehq.compose.crypto.ui.screens.detail.DetailScreen
import co.nimblehq.compose.crypto.ui.screens.home.HomeScreen

@Composable
fun AppNavigation(
navController: NavHostController = rememberNavController(),
startDestination: String = AppDestination.Home.destination
navController: NavHostController,
startDestination: String = AppDestination.Home.destination,
onCallBackChange: (() -> Unit) -> Unit,
globalDialogCallback: () -> Unit
) {

NavHost(
navController = navController,
startDestination = startDestination
Expand All @@ -24,7 +30,23 @@ fun AppNavigation(
composable(AppDestination.CoinDetail) {
DetailScreen(
navigator = { destination -> navController.navigate(destination) },
coinId = it.arguments?.getString(KEY_COIN_ID).orEmpty()
coinId = it.arguments?.getString(KEY_COIN_ID).orEmpty(),
onNetworkReconnected = { callback ->
onCallBackChange(callback)
}
)
}

dialog(AppDestination.NoNetwork.route) {
AppDialogPopUp(
onDismiss = { navController.popBackStack() },
onClick = {
navController.popBackStack()
globalDialogCallback()
},
message = stringResource(id = R.string.no_internet_message),
actionText = stringResource(id = android.R.string.ok),
title = stringResource(id = R.string.no_internet_title)
)
}
}
Expand All @@ -43,7 +65,7 @@ private fun NavGraphBuilder.composable(
)
}

private fun NavHostController.navigate(destination: AppDestination) {
fun NavHostController.navigate(destination: AppDestination) {
when (destination) {
is AppDestination.Up -> popBackStack()
else -> navigate(route = destination.destination)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package co.nimblehq.compose.crypto.ui.navigation

import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import co.nimblehq.compose.crypto.CryptoAppState
import co.nimblehq.compose.crypto.extension.collectAsEffect

@Composable
fun ComposeCryptoApp(
navController: NavHostController = rememberNavController(),
cryptoAppState: CryptoAppState
) {
var globalDialogCallback: () -> Unit = {}
val context = LocalContext.current

cryptoAppState.isNetworkConnected.collectAsEffect { isNetworkConnected ->
if (isNetworkConnected == false) {
val destination = AppDestination.NoNetwork

val currentRoute = navController.currentBackStackEntry?.destination?.route
if (currentRoute == AppDestination.NoNetwork.route) {
navController.popBackStack()
}

navController.navigate(destination)
}
}

cryptoAppState.networkError.collectAsEffect { error ->
Toast.makeText(context, error?.message, Toast.LENGTH_SHORT).show()
}

AppNavigation(
navController = navController,
onCallBackChange = {
globalDialogCallback = it
},
globalDialogCallback = {
globalDialogCallback.invoke()
},
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,35 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat
import co.nimblehq.compose.crypto.ui.navigation.AppNavigation
import co.nimblehq.compose.crypto.domain.usecase.IsNetworkConnectedUseCase
import co.nimblehq.compose.crypto.rememberCryptoAppState
import co.nimblehq.compose.crypto.ui.navigation.ComposeCryptoApp
import co.nimblehq.compose.crypto.ui.theme.ComposeTheme
import co.nimblehq.compose.crypto.util.DispatchersProvider
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

@Inject
lateinit var isNetworkConnectedUseCase: IsNetworkConnectedUseCase

@Inject
lateinit var dispatchersProvider: DispatchersProvider

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
ComposeTheme {
AppNavigation()
ComposeCryptoApp(
cryptoAppState = rememberCryptoAppState(
isNetworkConnectedUseCase = isNetworkConnectedUseCase,
dispatchersProvider = dispatchersProvider,
)
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ fun DetailScreen(
viewModel: DetailViewModel = hiltViewModel(),
navigator: (destination: AppDestination) -> Unit,
coinId: String,
onNetworkReconnected: (() -> Unit) -> Unit
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
Expand Down Expand Up @@ -83,6 +84,10 @@ fun DetailScreen(
LaunchedEffect(Unit) {
viewModel.input.getCoinId(coinId = coinId)
}

onNetworkReconnected {
viewModel.input.getCoinId(coinId = coinId)
}
}

@Suppress("LongMethod", "LongParameterList")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import co.nimblehq.compose.crypto.R
import co.nimblehq.compose.crypto.extension.boxShadow
import co.nimblehq.compose.crypto.lib.IsLoading
import co.nimblehq.compose.crypto.ui.base.LoadingState
import co.nimblehq.compose.crypto.ui.common.AppDialogPopUp
import co.nimblehq.compose.crypto.ui.navigation.AppDestination
import co.nimblehq.compose.crypto.ui.preview.HomeScreenParams
import co.nimblehq.compose.crypto.ui.preview.HomeScreenPreviewParameterProvider
Expand All @@ -41,8 +40,9 @@ const val TestTagCoinsLoader = "CoinsLoader"
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
navigator: (destination: AppDestination) -> Unit
navigator: (destination: AppDestination) -> Unit,
) {

val context = LocalContext.current
var rememberRefreshing by remember { mutableStateOf(false) }

Expand All @@ -57,9 +57,6 @@ fun HomeScreen(
}
}

// TODO remove in integration ticket
val isNetworkConnected by viewModel.isNetworkConnected.collectAsState()

val showMyCoinsLoading: IsLoading by viewModel.output.showMyCoinsLoading.collectAsState()
val showTrendingCoinsLoading: LoadingState by viewModel.output.showTrendingCoinsLoading.collectAsState()
val myCoins: List<CoinItemUiModel> by viewModel.output.myCoins.collectAsState()
Expand Down Expand Up @@ -90,17 +87,6 @@ fun HomeScreen(
onRefresh = { viewModel.input.loadData(isRefreshing = true) },
onTrendingCoinsLoadMore = { viewModel.input.getTrendingCoins(loadMore = true) }
)

// TODO remove in integration ticket
if (isNetworkConnected == false) {
AppDialogPopUp(
onDismiss = { /*TODO*/ },
onClick = { /*TODO*/ },
message = stringResource(id = R.string.no_internet_message),
actionText = stringResource(id = android.R.string.ok),
title = stringResource(id = R.string.no_internet_title)
)
}
}

@OptIn(ExperimentalMaterialApi::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ class HomeViewModel @Inject constructor(
dispatchers: DispatchersProvider,
private val getMyCoinsUseCase: GetMyCoinsUseCase,
private val getTrendingCoinsUseCase: GetTrendingCoinsUseCase,
private val isNetworkConnectedUseCase: IsNetworkConnectedUseCase,
) : BaseViewModel(dispatchers), Input, Output {

override val input = this
Expand Down Expand Up @@ -84,19 +83,8 @@ class HomeViewModel @Inject constructor(

private var trendingCoinsPage = MY_COINS_INITIAL_PAGE

// TODO remove in integration ticket
private val _isNetworkConnected = MutableStateFlow<Boolean?>(null)
val isNetworkConnected: StateFlow<Boolean?> = _isNetworkConnected

init {
loadData()
// TODO remove in integration ticket
execute {
isNetworkConnectedUseCase()
.collect {
_isNetworkConnected.emit(it)
}
}
}

override fun loadData(isRefreshing: Boolean) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ class DetailScreenTest : BaseViewModelTest() {
DetailScreen(
viewModel = viewModel,
navigator = { destination -> appDestination = destination },
coinId = "Bitcoin"
coinId = "Bitcoin",
onNetworkReconnected = {}
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,12 @@ class HomeScreenTest : BaseViewModelTest() {
private val mockGetMyCoinsUseCase = mockk<GetMyCoinsUseCase>()
private val mockGetTrendingCoinsUseCase = mockk<GetTrendingCoinsUseCase>()

// TODO remove in integration ticket
private val mockIsNetworkConnectedUseCase = mockk<IsNetworkConnectedUseCase>()

private lateinit var viewModel: HomeViewModel

private var appDestination: AppDestination? = null

@Before
fun setUp() {
every { mockIsNetworkConnectedUseCase() } returns flowOf(null)
composeAndroidTestRule.activity.setContent {
HomeScreen(
viewModel = viewModel,
Expand Down Expand Up @@ -219,8 +215,7 @@ class HomeScreenTest : BaseViewModelTest() {
viewModel = HomeViewModel(
dispatchers = testDispatcherProvider,
getMyCoinsUseCase = mockGetMyCoinsUseCase,
getTrendingCoinsUseCase = mockGetTrendingCoinsUseCase,
isNetworkConnectedUseCase = mockIsNetworkConnectedUseCase
getTrendingCoinsUseCase = mockGetTrendingCoinsUseCase
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ class HomeViewModelTest : BaseViewModelTest() {
private val mockGetTrendingCoinsUseCase = mockk<GetTrendingCoinsUseCase>()
private lateinit var viewModel: HomeViewModel

// TODO remove in integration ticket
private val mockIsNetworkConnectedUseCase = mockk<IsNetworkConnectedUseCase>()

@Before
fun setUp() {
every { mockGetMyCoinsUseCase.execute(any()) } returns flowOf(MockUtil.myCoins)
Expand Down Expand Up @@ -145,8 +142,7 @@ class HomeViewModelTest : BaseViewModelTest() {
viewModel = HomeViewModel(
testDispatcherProvider,
mockGetMyCoinsUseCase,
mockGetTrendingCoinsUseCase,
mockIsNetworkConnectedUseCase
mockGetTrendingCoinsUseCase
)
}
}