Implementing a groupBy extension for Kotlin Flow - android

I have a flow like this: Flow<List<Transaction>>
Each Transaction object has a Category object
What i want is an extension function that groups the transactions per Category
This is what i've tried:
inline fun <T, K> Flow<Iterable<T>>.groupIterableBy(crossinline keySelector: (T) -> K): Flow<Map<K, MutableList<T>>> = map {
val storage = HashMap<K, MutableList<T>>()
it.map{ element ->
val key = keySelector(element)
if (storage[key] == null){
storage[key] = mutableListOf()
}
storage[key]!!.add(element)
}
return#map storage
}
This works great, but i don't feel like this is programmed in a clean way.
Does anyone have suggestions for making this function cleaner?

Following IR42's comment, this should work. Why not use
Iterable.groupBy()
Example scratch:
data class Tra(
val cat: Cat
)
data class Cat(
val name: String
)
val flow = flowOf(
listOf(
Tra(Cat("A")),
Tra(Cat("B")),
Tra(Cat("D")),
Tra(Cat("B"))
),
listOf(
Tra(Cat("A")),
Tra(Cat("C")),
Tra(Cat("B")),
Tra(Cat("A")),
Tra(Cat("C"))
)
)
inline fun <T, K> Flow<Iterable<T>>.groupIterableBy(crossinline keySelector: (T) -> K): Flow<Map<K, List<T>>> =
map { it.groupBy(keySelector) }
val groupedFlow = flow.groupIterableBy{it.cat.name}
runBlocking {
groupedFlow
.collect {
println(it)
}
}
It prints:
{A=[Tra(cat=Cat(name=A))], B=[Tra(cat=Cat(name=B)), Tra(cat=Cat(name=B))], D=[Tra(cat=Cat(name=D))]}
{A=[Tra(cat=Cat(name=A)), Tra(cat=Cat(name=A))], C=[Tra(cat=Cat(name=C)), Tra(cat=Cat(name=C))], B=[Tra(cat=Cat(name=B))]}
Is this the result you're looking for?

Related

Refresh Data in ViewModel when Navigating back - Android(Kotlin)

I have the following setup.
I have a screen with a list of items (PlantsScreen). When clicking on an item from the list I will be navigated to another screen (AddEditPlantScreen). After editing and saving the item and navigating back to the listScreen, I want to show the updated list of items. But the list is not displaying the updated list but the list before the edit of the item.
In order to have a single source of truth, I am fetching the data from a node.js Back-End and then saving it to the local repository (Room). I think I need to refresh the state in the ViewModel to fetch the updated list from my repository.
I know I can use a Job to do this, but it throws me an error. Is this the correct approach when returning a Flow?
If yes, how can I achieve this.
If not, what alternative approach do I have?
plantsListViewModel.kt
private val _state = mutableStateOf<PlantsState>(PlantsState())
val state: State<PlantsState> = _state
init {
getPlants(true, "")
}
private fun getPlants(fetchFromBackend: Boolean, query: String) {
viewModelScope.launch {
plantRepository.getPlants(fetchFromBackend, query)
.collect { result ->
when (result) {
is Resource.Success -> {
result.data?.let { plants ->
_state.value = state.value.copy(
plants = plants,
)
}
}
}
}
}
}
Here is my repository where I fetch the items in the list from.
// plantsRepository.kt
override suspend fun getPlants(
fetchFromBackend: Boolean,
query: String
): Flow<Resource<List<Plant>>> {
return flow {
emit(Resource.Loading(true))
val localPlants = dao.searchPlants(query)
emit(
Resource.Success(
data = localPlants.map { it.toPlant() },
)
)
val isDbEmpty = localPlants.isEmpty() && query.isBlank()
val shouldLoadFromCache = !isDbEmpty && !fetchFromBackend
if (shouldLoadFromCache) {
emit(Resource.Loading(false))
return#flow
}
val response = plantApi.getPlants().plants
dao.clearPlants()
dao.insertPlants(
response.map { it.toPlantEntity() }
)
emit(Resource.Success(
data = dao.searchPlants("").map { it.toPlant() }
))
emit(Resource.Loading(false))
}
}
The full code for reference can be found here:
https://gitlab.com/fiehra/plants
Thank you!
You actually have two sources of truth: One is the room database, the other the _state object in the view model.
To reduce this to a single source of truth you need to move the collection of the flow to the compose function where the data is needed. You will do this using the extension function StateFlow.collectAsStateWithLifecycle() from the artifact androidx.lifecycle:lifecycle-runtime-compose. This will automatically subscribe and unsubscribe the flow when your composable enters and leaves the composition.
Since you want the business logic to stay in the view model you have to apply it before the flow is collected. The idea is to only transform the flow in the view model:
class PlantsViewModel {
private var fetchFromBackend: Boolean by mutableStateOf(true)
private var query: String by mutableStateOf("")
#OptIn(ExperimentalCoroutinesApi::class)
val state: StateFlow<PlantsState> =
snapshotFlow { fetchFromBackend to query }
.flatMapLatest { plantRepository.getPlants(it.first, it.second) }
.mapLatest(PlantsState::of)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = PlantsState.Loading,
)
// ...
}
If you want other values for fetchFromBackend and query you just need to update the variables; the flow will automatically recalculate the state object. It can be as simple as just calling something like this:
fun requestPlant(fetchFromBackend: Boolean, query: String) {
this.fetchFromBackend = fetchFromBackend
this.query = query
}
The logic to create a PlantsState from a result can then be done somewhere else in the view model. Replace your PlantsViewModel.getPlants() with this and place it at file level outside of the PlantsViewModel class:
private fun PlantsState.Companion.of(result: Resource<List<Plant>>): PlantsState = when (result) {
is Resource.Success -> {
result.data?.let { plants ->
PlantsState.Success(
plants = plants,
)
} ?: TODO("handle case where result.data is null")
}
is Resource.Error -> {
PlantsState.Error("an error occurred")
}
is Resource.Loading -> {
PlantsState.Loading
}
}
With the PlantsState class replaced by this:
sealed interface PlantsState {
object Loading : PlantsState
data class Success(
val plants: List<Plant> = emptyList(),
val plantOrder: PlantOrder = PlantOrder.Name(OrderType.Descending),
val isOrderSectionVisible: Boolean = false,
) : PlantsState
data class Error(
val error: String,
) : PlantsState
companion object
}
Then, wherever you need the state (in PlantsScreen f.e.), you can get a state object with
val state by viewModel.state.collectAsStateWithLifecycle()
Thanks to kotlin flows state will always contain the most current data from the room database, and thanks to the compose magic your composables will always update when anything in the state object updates, so that you really only have one single source of truth.
Additionally:
PlantRepository.getPlants() should not be marked as a suspend function because it just creates a flow and won't block; long running data retrieval will be done in the collector.
You will need to manually import androidx.compose.runtime.getValue and the androidx.compose.runtime.setValue for some of the delegates to work.
After #Leviathan was able to point me in the right direction i refactored my code by changing the return types of my repository functions, implementing use cases and returning a Flow<List<Plant>> instead of Flow<Resource<List<Plant>>> for simplicity purposes.
Further removed the suspend marker of the functions in the PlantDao.kt and PlantRepository.kt as pointed out by Leviathan.
// PlantRepositoryImplementation.kt
override fun getPlants(
fetchFromBackend: Boolean,
query: String
): Flow<List<Plant>> {
return flow {
if (fetchFromBackend) {
val response = plantApi.getPlants().plants
dao.clearPlants()
dao.insertPlants(
response.map { it.toPlantEntity() }
)
val localPlants = dao.searchPlants(query)
localPlants.collect { plants ->
emit(plants.map { it.toPlant() })
return#collect
}
} else {
val localPlants = dao.searchPlants(query)
localPlants.collect { plants ->
emit(plants.map { it.toPlant() })
return#collect
}
}
}
}
I started using a Job and GetPlants usecase in my viewModel like this:
// PlantsViewModel.kt
private fun getPlants(plantOrder: PlantOrder, fetchFromBackend: Boolean, query: String) {
getPlantsJob?.cancel()
getPlantsJob = plantUseCases.getPlants(plantOrder, fetchFromBackend, query)
.onEach { plants ->
_state.value = state.value.copy(
plants = plants,
plantOrder = plantOrder
)
}.launchIn(viewModelScope)
I also had to remove the suspend in the PlantDao.kt
// PlantDao.kt
fun searchPlants(query: String): Flow<List<PlantEntity>>
This is the code for my GetPlants usecase:
// GetPlantsUsecase.kt
class GetPlants
(
private val repository: PlantRepository,
) {
operator fun invoke(
plantOrder: PlantOrder = PlantOrder.Name(OrderType.Descending),
fetchFromBackend: Boolean,
query: String
): Flow<List<Plant>> {
return repository.getPlants(fetchFromBackend, query).map { plants ->
when (plantOrder.orderType) {
is OrderType.Ascending -> {
// logic for sorting
}
}
is OrderType.Descending -> {
// logic for sorting
}
}
}
}
}

How to schedule an API request asynchronously for one composable screen from another composable screen? (Jetpack Compose)

I'm a junior Android developer and trying to build a Facebook-like social media app. My issue is that when I bookmark a post in Screen B and the action succeeds, (1) I want to launch an API request in Screen A while in Screen B and (2) update the bookmarked icon ONLY for that particular post.
For the second part of the issue, I tried these two solutions.
I relaunched a manual API request on navigating back to Screen A. This updates the whole list when there's only one small change, hence very inefficient.
I built another URL route to fetch that updated post only and launched it on navigating back to Screen A. But to insert the newly updated post at the old index, the list has to be mutable and I ain't sure this is a good practice.
Please help me on how to solve this issue or similar issues. I'm not sure if this should be done by passing NavArg to update locally and then some or by using web sockets. Thanks in advance.
data class ScreenAState(
val posts: List<Post> = emptyList(),
val isLoading: Boolean = false)
data class ScreenBState(
val post: PostDetail? = null,
val isBookmarked: Boolean? = null)
data class Post(
val title: String,
val isBookMarked: Boolean,
val imageUrl: String)
data class PostDetail(
val title: String,
val content: String,
val isBookMarked: Boolean,
val imageUrl: String)
I suggest you continue with using your logic that will update your list on return from screen B to screen A, but instead of using simple list, you could use:
https://developer.android.com/reference/kotlin/androidx/compose/runtime/snapshots/SnapshotStateList
This list is designed for what you need I think. Update just that one element.
In mean time, you can change that item from list to some loading dummy item, if you want to have loading like view while you wait for API call to finish.
The problem is how to handle data consistency, which is not directly related to jetpack compose. I suggest you solve this problem at the model level. Return flow instead of static data in the repository, and use collectAsState in the jetpack compose to monitor data changes.
It's hard to give an example, because it depends on the type of Model layer. If it's a database, androidx's room library supports returning flow; if it's a network, take a look at this.
https://gist.github.com/FishHawk/6e4706646401bea20242bdfad5d86a9e
Triggering a refresh is not a good option. It is better to maintain an ActionChannel in the repository for each list that is monitored. use the ActionChannel to modify the list locally to notify compose of the update.
For example, you can make a PagedList if the data layer is network. With onStart and onClose, channels can be added or removed from the repository, thus giving the repository the ability to update all the observed lists.
sealed interface RemoteListAction<out T> {
data class Mutate<T>(val transformer: (MutableList<T>) -> MutableList<T>) : RemoteListAction<T>
object Reload : RemoteListAction<Nothing>
object RequestNextPage : RemoteListAction<Nothing>
}
typealias RemoteListActionChannel<T> = Channel<RemoteListAction<T>>
suspend fun <T> RemoteListActionChannel<T>.mutate(transformer: (MutableList<T>) -> MutableList<T>) {
send(RemoteListAction.Mutate(transformer))
}
suspend fun <T> RemoteListActionChannel<T>.reload() {
send(RemoteListAction.Reload)
}
suspend fun <T> RemoteListActionChannel<T>.requestNextPage() {
send(RemoteListAction.RequestNextPage)
}
class RemoteList<T>(
private val actionChannel: RemoteListActionChannel<T>,
val value: Result<PagedList<T>>?,
) {
suspend fun mutate(transformer: (MutableList<T>) -> MutableList<T>) =
actionChannel.mutate(transformer)
suspend fun reload() = actionChannel.reload()
suspend fun requestNextPage() = actionChannel.requestNextPage()
}
data class PagedList<T>(
val list: List<T>,
val appendState: Result<Unit>?,
)
data class Page<Key : Any, T>(
val data: List<T>,
val nextKey: Key?,
)
fun <Key : Any, T> remotePagingList(
startKey: Key,
loader: suspend (Key) -> Result<Page<Key, T>>,
onStart: ((actionChannel: RemoteListActionChannel<T>) -> Unit)? = null,
onClose: ((actionChannel: RemoteListActionChannel<T>) -> Unit)? = null,
): Flow<RemoteList<T>> = callbackFlow {
val dispatcher = Dispatchers.IO.limitedParallelism(1)
val actionChannel = Channel<RemoteListAction<T>>()
var listState: Result<Unit>? = null
var appendState: Result<Unit>? = null
var value: MutableList<T> = mutableListOf()
var nextKey: Key? = startKey
onStart?.invoke(actionChannel)
suspend fun mySend() {
send(
RemoteList(
actionChannel = actionChannel,
value = listState?.map {
PagedList(
appendState = appendState,
list = value,
)
},
)
)
}
fun requestNextPage() = launch(dispatcher) {
nextKey?.let { key ->
appendState = null
mySend()
loader(key)
.onSuccess {
value.addAll(it.data)
nextKey = it.nextKey
listState = Result.success(Unit)
appendState = Result.success(Unit)
mySend()
}
.onFailure {
if (listState?.isSuccess != true)
listState = Result.failure(it)
appendState = Result.failure(it)
mySend()
}
}
}
var job = requestNextPage()
launch(dispatcher) {
actionChannel.receiveAsFlow().flowOn(dispatcher).collect { action ->
when (action) {
is RemoteListAction.Mutate -> {
value = action.transformer(value)
mySend()
}
is RemoteListAction.Reload -> {
job.cancel()
listState = null
appendState = null
value.clear()
nextKey = startKey
mySend()
job = requestNextPage()
}
is RemoteListAction.RequestNextPage -> {
if (!job.isActive) job = requestNextPage()
}
}
}
}
launch(dispatcher) {
Connectivity.instance?.interfaceName?.collect {
if (job.isActive) {
job.cancel()
job = requestNextPage()
}
}
}
awaitClose {
onClose?.invoke(actionChannel)
}
}
And in repository:
val postListActionChannels = mutableListOf<RemoteListActionChannel<Post>>()
suspend fun listPost() =
daoFlow.filterNotNull().flatMapLatest {
remotePagingList(
startKey = 0,
loader = { page ->
it.mapCatching { dao ->
/* dao function, simulate network operation, return List<Post> */
dao.listPost(page)
}.map { Page(it, if (it.isEmpty()) null else page + 1) }
},
onStart = { postListActionChannels.add(it) },
onClose = { postListActionChannels.remove(it) },
)
}
suspend fun markPost(title: String) =
oneshot {
/* dao function, simulate network operation, return Unit */
it.markPost(title)
}.onSuccess {
postListActionChannels.forEach { ch ->
ch.mutate { list ->
list.map {
if (it.title == title && !it.isBookMarked)
it.copy(isBookMarked = true)
else it
}.toMutableList()
}
}
}

Implementing Google places autoComplete textfield implementation in jetpack compose android

Did anyone implement google autocomplete suggestion text field or fragment in a jetpack compose project? If so kindly guide or share code snippets as I'm having difficulty in implementing it.
Update
Here is the intent that I'm triggering to open full-screen dialog, but when I start typing within it gets closed, and also I'm unable to figure out what the issue is and need a clue about handling on activity result for reading the result of the predictions within this compose function.
Places.initialize(context, "sa")
val fields = listOf(Place.Field.ID, Place.Field.NAME)
val intent = Autocomplete.IntentBuilder(
AutocompleteActivityMode.FULLSCREEN,fields).build(context)
startActivityForResult(context as MainActivity,intent, AUTOCOMPLETE_REQUEST_CODE, Bundle.EMPTY)
I am using the MVVM architecture and this is how I implemented it:
GooglePlacesApi
I've created an api for reaching google api named GooglePlacesApi
interface GooglePlacesApi {
#GET("maps/api/place/autocomplete/json")
suspend fun getPredictions(
#Query("key") key: String = <GOOGLE_API_KEY>,
#Query("types") types: String = "address",
#Query("input") input: String
): GooglePredictionsResponse
companion object{
const val BASE_URL = "https://maps.googleapis.com/"
}
}
The #Query("types") field is for specifiying what are you looking for in the query, you can look for establishments etc.
Types can be found here
Models
So I created 3 models for this implementation:
GooglePredictionsResponse
The way the response looks if you are doing a GET request with postman is:
Google Prediction Response
You can see that we have an object with "predictions" key so this is our first model.
data class GooglePredictionsResponse(
val predictions: ArrayList<GooglePrediction>
)
GooglePredictionTerm
data class GooglePredictionTerm(
val offset: Int,
val value: String
)
GooglePrediction
data class GooglePrediction(
val description: String,
val terms: List<GooglePredictionTerm>
)
I only needed that information, if you need anything else, feel free to modify the models or create your own.
GooglePlacesRepository
And finally we create the repository to get the information (I'm using hilt to inject my dependencies, you can ignore those annotations if not using it)
#ActivityScoped
class GooglePlacesRepository #Inject constructor(
private val api: GooglePlacesApi,
){
suspend fun getPredictions(input: String): Resource<GooglePredictionsResponse>{
val response = try {
api.getPredictions(input = input)
} catch (e: Exception) {
Log.d("Rently", "Exception: ${e}")
return Resource.Error("Failed prediction")
}
return Resource.Success(response)
}
}
Here I've used an extra class I've created to handle the response, called Resource
sealed class Resource<T>(val data: T? = null, val message: String? = null){
class Success<T>(data: T): Resource<T>(data)
class Error<T>(message: String, data:T? = null): Resource<T>(data = data, message = message)
class Loading<T>(data: T? = null): Resource<T>(data = data)
}
View Model
Again I'm using hilt so ignore annotations if not using it.
#HiltViewModel
class AddApartmentViewModel #Inject constructor(private val googleRepository: GooglePlacesRepository): ViewModel(){
val isLoading = mutableStateOf(false)
val predictions = mutableStateOf(ArrayList<GooglePrediction>())
fun getPredictions(address: String) {
viewModelScope.launch {
isLoading.value = true
val response = googleRepository.getPredictions(input = address)
when(response){
is Resource.Success -> {
predictions.value = response.data?.predictions!!
}
}
isLoading.value = false
}
}
fun onSearchAddressChange(address: String){
getPredictions(address)
}
}
If you need any further help let me know
I didn't include UI implementation because I assume it is individual but this is the easier part ;)
#Composable
fun MyComponent() {
val context = LocalContext.current
val intentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) {
when (it.resultCode) {
Activity.RESULT_OK -> {
it.data?.let {
val place = Autocomplete.getPlaceFromIntent(it)
Log.i("MAP_ACTIVITY", "Place: ${place.name}, ${place.id}")
}
}
AutocompleteActivity.RESULT_ERROR -> {
it.data?.let {
val status = Autocomplete.getStatusFromIntent(it)
Log.i("MAP_ACTIVITY", "Place: ${place.name}, ${place.id}")
}
}
Activity.RESULT_CANCELED -> {
// The user canceled the operation.
}
}
}
val launchMapInputOverlay = {
Places.initialize(context, YOUR_API_KEY)
val fields = listOf(Place.Field.ID, Place.Field.NAME)
val intent = Autocomplete
.IntentBuilder(AutocompleteActivityMode.OVERLAY, fields)
.build(context)
intentLauncher.launch(intent)
}
Column {
Button(onClick = launchMapInputOverlay) {
Text("Select Location")
}
}
}

Pass Parcelable argument with compose navigation

I want to pass a parcelable object (BluetoothDevice) to a composable using compose navigation.
Passing primitive types is easy:
composable(
"profile/{userId}",
arguments = listOf(navArgument("userId") { type = NavType.StringType })
) {...}
navController.navigate("profile/user1234")
But I can't pass a parcelable object in the route unless I can serialize it to a string.
composable(
"deviceDetails/{device}",
arguments = listOf(navArgument("device") { type = NavType.ParcelableType(BluetoothDevice::class.java) })
) {...}
val device: BluetoothDevice = ...
navController.navigate("deviceDetails/$device")
The code above obviously doesn't work because it just implicitly calls toString().
Is there a way to either serialize a Parcelable to a String so I can pass it in the route or pass the navigation argument as an object with a function other than navigate(route: String)?
Warning:
Ian Lake is an Android Developer Advocate and he says in this answer that pass complex data structures is an anti-pattern (referring the documentation). He works on this library, so he has authority on this. Use the approach below by your own.
Edit: Updated to Compose Navigation 2.4.0-beta07
Seems like previous solution is not supported anymore. Now you need to create a custom NavType.
Let's say you have a class like:
#Parcelize
data class Device(val id: String, val name: String) : Parcelable
Then you need to define a NavType
class AssetParamType : NavType<Device>(isNullableAllowed = false) {
override fun get(bundle: Bundle, key: String): Device? {
return bundle.getParcelable(key)
}
override fun parseValue(value: String): Device {
return Gson().fromJson(value, Device::class.java)
}
override fun put(bundle: Bundle, key: String, value: Device) {
bundle.putParcelable(key, value)
}
}
Notice that I'm using Gson to convert the object to a JSON string. But you can use the conversor that you prefer...
Then declare your composable like this:
NavHost(...) {
composable("home") {
Home(
onClick = {
val device = Device("1", "My device")
val json = Uri.encode(Gson().toJson(device))
navController.navigate("details/$json")
}
)
}
composable(
"details/{device}",
arguments = listOf(
navArgument("device") {
type = AssetParamType()
}
)
) {
val device = it.arguments?.getParcelable<Device>("device")
Details(device)
}
}
Original answer
Basically you can do the following:
// In the source screen...
navController.currentBackStackEntry?.arguments =
Bundle().apply {
putParcelable("bt_device", device)
}
navController.navigate("deviceDetails")
And in the details screen...
val device = navController.previousBackStackEntry
?.arguments?.getParcelable<BluetoothDevice>("bt_device")
I've written a small extension for the NavController.
import android.os.Bundle
import androidx.core.net.toUri
import androidx.navigation.*
fun NavController.navigate(
route: String,
args: Bundle,
navOptions: NavOptions? = null,
navigatorExtras: Navigator.Extras? = null
) {
val routeLink = NavDeepLinkRequest
.Builder
.fromUri(NavDestination.createRoute(route).toUri())
.build()
val deepLinkMatch = graph.matchDeepLink(routeLink)
if (deepLinkMatch != null) {
val destination = deepLinkMatch.destination
val id = destination.id
navigate(id, args, navOptions, navigatorExtras)
} else {
navigate(route, navOptions, navigatorExtras)
}
}
As you can check there are at least 16 functions "navigate" with different parameters, so it's just a converter for use
public open fun navigate(#IdRes resId: Int, args: Bundle?)
So using this extension you can use Compose Navigation without these terrible deep link parameters for arguments at routes.
Here's my version of using the BackStackEntry
Usage:
composable("your_route") { entry ->
AwesomeScreen(entry.requiredArg("your_arg_key"))
}
navController.navigate("your_route", "your_arg_key" to yourArg)
Extensions:
fun NavController.navigate(route: String, vararg args: Pair<String, Parcelable>) {
navigate(route)
requireNotNull(currentBackStackEntry?.arguments).apply {
args.forEach { (key: String, arg: Parcelable) ->
putParcelable(key, arg)
}
}
}
inline fun <reified T : Parcelable> NavBackStackEntry.requiredArg(key: String): T {
return requireNotNull(arguments) { "arguments bundle is null" }.run {
requireNotNull(getParcelable(key)) { "argument for $key is null" }
}
}
Here is another solution that works also by adding the Parcelable to the correct NavBackStackEntry, NOT the previous entry. The idea is first to call navController.navigate, then add the argument to the last NavBackStackEntry.arguments in the NavController.backQueue. Be mindful that this does use another library group restricted API (annotated with RestrictTo(LIBRARY_GROUP)), so could potentially break. Solutions posted by some others use the restricted NavBackStackEntry.arguments, however NavController.backQueue is also restricted.
Here are some extensions for the NavController for navigating and NavBackStackEntry for retrieving the arguments within the route composable:
fun NavController.navigate(
route: String,
navOptions: NavOptions? = null,
navigatorExtras: Navigator.Extras? = null,
args: List<Pair<String, Parcelable>>? = null,
) {
if (args == null || args.isEmpty()) {
navigate(route, navOptions, navigatorExtras)
return
}
navigate(route, navOptions, navigatorExtras)
val addedEntry: NavBackStackEntry = backQueue.last()
val argumentBundle: Bundle = addedEntry.arguments ?: Bundle().also {
addedEntry.arguments = it
}
args.forEach { (key, arg) ->
argumentBundle.putParcelable(key, arg)
}
}
inline fun <reified T : Parcelable> NavController.navigate(
route: String,
navOptions: NavOptions? = null,
navigatorExtras: Navigator.Extras? = null,
arg: T? = null,
) {
if (arg == null) {
navigate(route, navOptions, navigatorExtras)
return
}
navigate(
route = route,
navOptions = navOptions,
navigatorExtras = navigatorExtras,
args = listOf(T::class.qualifiedName!! to arg),
)
}
fun NavBackStackEntry.requiredArguments(): Bundle = arguments ?: throw IllegalStateException("Arguments were expected, but none were provided!")
#Composable
inline fun <reified T : Parcelable> NavBackStackEntry.rememberRequiredArgument(
key: String = T::class.qualifiedName!!,
): T = remember {
requiredArguments().getParcelable<T>(key) ?: throw IllegalStateException("Expected argument with key: $key of type: ${T::class.qualifiedName!!}")
}
#Composable
inline fun <reified T : Parcelable> NavBackStackEntry.rememberArgument(
key: String = T::class.qualifiedName!!,
): T? = remember {
arguments?.getParcelable(key)
}
To navigate with a single argument, you can now do this in the scope of a NavGraphBuilder:
composable(route = "screen_1") {
Button(
onClick = {
navController.navigate(
route = "screen_2",
arg = MyParcelableArgument(whatever = "whatever"),
)
}
) {
Text("goto screen 2")
}
}
composable(route = "screen_2") { entry ->
val arg: MyParcelableArgument = entry.rememberRequiredArgument()
// TODO: do something with arg
}
Or if you want to pass multiple arguments of the same type:
composable(route = "screen_1") {
Button(
onClick = {
navController.navigate(
route = "screen_2",
args = listOf(
"arg_1" to MyParcelableArgument(whatever = "whatever"),
"arg_2" to MyParcelableArgument(whatever = "whatever"),
),
)
}
) {
Text("goto screen 2")
}
}
composable(route = "screen_2") { entry ->
val arg1: MyParcelableArgument = entry.rememberRequiredArgument(key = "arg_1")
val arg2: MyParcelableArgument = entry.rememberRequiredArgument(key = "arg_2")
// TODO: do something with args
}
The key benefit of this approach is that similar to the answer that uses Moshi to serialise the argument, it will work when popUpTo is used in the navOptions, but will also be more efficient as no JSON serialisation is involved.
This will of course not work with deep links, but it will survive process or activity recreation. For cases where you need to support deep links or even just optional arguments to navigation routes, you can use the entry.rememberArgument extension. Unlike entry.rememberRequiredArgument, it will return null instead of throwing an IllegalStateException.
The backStackEntry solution given by #nglauber will not work if we pop up (popUpTo(...)) back stacks on navigate(...).
So here is another solution. We can pass the object by converting it to a JSON string.
Example code:
val ROUTE_USER_DETAILS = "user-details?user={user}"
// Pass data (I am using Moshi here)
val user = User(id = 1, name = "John Doe") // User is a data class.
val moshi = Moshi.Builder().build()
val jsonAdapter = moshi.adapter(User::class.java).lenient()
val userJson = jsonAdapter.toJson(user)
navController.navigate(
ROUTE_USER_DETAILS.replace("{user}", userJson)
)
// Receive Data
NavHost {
composable(ROUTE_USER_DETAILS) { backStackEntry ->
val userJson = backStackEntry.arguments?.getString("user")
val moshi = Moshi.Builder().build()
val jsonAdapter = moshi.adapter(User::class.java).lenient()
val userObject = jsonAdapter.fromJson(userJson)
UserDetailsView(userObject) // Here UserDetailsView is a composable.
}
}
// Composable function/view
#Composable
fun UserDetailsView(
user: User
){
// ...
}
I get a weird bug when I implement the top answer to this question. Like I have the following Parcelable to pass between two screens of my Jetpack Compose app:
#Parcelize
data class EdgeParcelable(
val node: NodeParcelable?,
val cursor: String?,
) : Parcelable
And as the accepted Answer says I have implemented a custom NavType:
class EdgeParcelableType : NavType<EdgeParcelable>(isNullableAllowed = false) {
override val name: String
get() = "edge"
override fun get(bundle: Bundle, key: String): EdgeParcelable? {
return bundle.getParcelable(key)
}
override fun parseValue(value: String): EdgeParcelable {
return Gson().fromJson(value, EdgeParcelable::class.java)
}
override fun put(bundle: Bundle, key: String, value: EdgeParcelable) {
bundle.putParcelable(key, value)
}
}
And in my Composable function where I create the NavHost I have:
#Composable
fun MyApp(viewModel: MyViewModel, modifier: Modifier = Modifier) {
val navController = rememberNavController()
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = { MyTopAppBar(
currentScreen=currentScreen,
canNavigateBack = navController.previousBackStackEntry != null,
navigateUp = { navController.navigateUp() }
) }
) { innerPadding ->
NavHost(
navController = navController,
startDestination = "home",
modifier = Modifier.padding(innerPadding)
) {
composable(route = "home") {
val lazyPagingItems = viewModel.Items()
HomeScreen(
lazyPagingItems = lazyPagingItems,
onTownClicked = { edge: EdgeParcelable ->
val json = Uri.encode(Gson().toJson(edgeParcelable))
navController.navigateSingleTopTo("hotspots/$json")
},
modifier = ...
)
}
composable(
route = "hotspots/{edge}",
arguments = listOf(
navArgument("edge") {
type = EdgeParcelableType()
}
)
) {
val edgeParcelable = it.arguments?.getParcelable<EdgeParcelable>("edge")
HotspotsScreen(edgeParcelable)
}
}
}
}
The code above crashes my Application when I add the lines:
val bsEntry by navController.currentBackStackEntryAsState()
val currentScreen = bsEntry?.destination?.route ?: "Home"
Adding the above lines make the Composable become:
#Composable
fun MyApp(viewModel: MyViewModel, modifier: Modifier = Modifier) {
val navController = rememberNavController()
// Adding these causes a problem...
val bsEntry by navController.currentBackStackEntryAsState()
val currentScreen = bsEntry?.destination?.route ?: "Home"
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = { MyTopAppBar(
currentScreen=currentScreen,
canNavigateBack = navController.previousBackStackEntry != null,
navigateUp = { navController.navigateUp() }
) }
) { innerPadding ->
NavHost(
navController = navController,
startDestination = "home",
modifier = Modifier.padding(innerPadding)
) {
composable(route = "home") {
val lazyPagingItems = viewModel.Items()
HomeScreen(
lazyPagingItems = lazyPagingItems,
onTownClicked = { edge: EdgeParcelable ->
val json = Uri.encode(Gson().toJson(edgeParcelable))
navController.navigateSingleTopTo("hotspots/$json")
},
modifier = ...
)
}
composable(
route = "hotspots/{edge}",
arguments = listOf(
navArgument("edge") {
type = EdgeParcelableType()
}
)
) {
val edgeParcelable = it.arguments?.getParcelable<EdgeParcelable>("edge")
HotspotsScreen(edgeParcelable)
}
}
}
}
Passing my Custom NavType with the following line of code:
arguments = listOf(navArgument("edge") { type = EdgeParcelableType() } )
now crashes my app, by rendering it unusable. The app seems to choke on itself, almost like, the Navigation API does not really understand the new Custom EdgeParcleableType() or perhaps something is missing that remains to be added to make this EdgeParcelableType work well with the Navigation API.
I was only able to solve the problem by changing the type above to StringType as follows:
arguments = listOf( navArgument("edge") { type = NavType.StringType }
And passing around strings in the rest of the Composable as follows:
#Composable
fun MyApp(viewModel: MyViewModel, modifier: Modifier = Modifier) {
val navController = rememberNavController()
// Using NavType.StringType allows this work...
val bsEntry by navController.currentBackStackEntryAsState()
val currentScreen = bsEntry?.destination?.route ?: "Home"
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = { MyTopAppBar(
currentScreen=currentScreen,
canNavigateBack = navController.previousBackStackEntry != null,
navigateUp = { navController.navigateUp() }
) }
) { innerPadding ->
NavHost(
navController = navController,
startDestination = "home",
modifier = Modifier.padding(innerPadding)
) {
composable(route = "home") {
val lazyPagingItems = viewModel.Items()
HomeScreen(
lazyPagingItems = lazyPagingItems,
onTownClicked = { edge: EdgeParcelable ->
val json = Uri.encode(Gson().toJson(edgeParcelable))
navController.navigateSingleTopTo("hotspots/$json")
},
modifier = ...
)
}
composable(
route = "hotspots/{edge}",
arguments = listOf( navArgument("edge") {
type = NavType.StringType
}
)
) {
val edgeParcelable = Gson().fromJson(Uri.decode(it.arguments?.getString("edge")), EdgeParcelable::class.java)
HotspotsScreen(edgeParcelable)
}
}
}
}
Then my app worked like a Charm. Sorting this took me like 2 days of trial and error and searching so I hope this can help someone out there if faced with a similar issue...
Following nglauber suggestion, I've created two extensions which are helping me a bit
#Suppress("UNCHECKED_CAST")
fun <T> NavHostController.getArgument(name: String): T {
return previousBackStackEntry?.arguments?.getSerializable(name) as? T
?: throw IllegalArgumentException()
}
fun NavHostController.putArgument(name: String, arg: Serializable?) {
currentBackStackEntry?.arguments?.putSerializable(name, arg)
}
And I use them this way:
Source:
navController.putArgument(NavigationScreens.Pdp.Args.game, game)
navController.navigate(NavigationScreens.Pdp.route)
Destination:
val game = navController.getArgument<Game>(NavigationScreens.Pdp.Args.game)
PdpScreen(game)
A very simple and basic way to do is as below
1.First create the parcelable object that you want to pass e.g
#Parcelize
data class User(
val name: String,
val phoneNumber:String
) : Parcelable
2.Then in the current composable that you are in e.g main screen
val userDetails = UserDetails(
name = "emma",
phoneNumber = "1234"
)
)
navController.currentBackStackEntry?.arguments?.apply {
putParcelable("userDetails",userDetails)
}
navController.navigate(Destination.DetailsScreen.route)
3.Then in the details composable, make sure you pass to it a navcontroller as an parameter e.g.
#Composable
fun Details (navController:NavController){
val data = remember {
mutableStateOf(navController.previousBackStackEntry?.arguments?.getParcelable<UserDetails>("userDetails")!!)
}
}
N.B: If the parcelable is not passed into state, you will receive an error when navigating back
My approach with Moshi:
Routes
sealed class Route(
private val route: String,
val Key: String = "",
) {
object Main : Route(route = "main")
object Profile : Route(route = "profile", Key = "user")
override fun toString(): String {
return when {
Key.isNotEmpty() -> "$route/{$Key}"
else -> route
}
}
}
Extension
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.core.net.toUri
import androidx.navigation.*
import com.squareup.moshi.Moshi
inline fun <reified T> NavController.navigate(
route: String,
data: Pair<String, T>,
navOptions: NavOptions? = null,
navigatorExtras: Navigator.Extras? = null,
) {
val count = route
.split("{${data.first}}")
.size
.dec()
if (count != 1) {
throw IllegalArgumentException()
}
val out = Moshi.Builder()
.build()
.adapter(T::class.java)
.toJson(data.second)
val newRoute = route.replace(
oldValue = "{${data.first}}",
newValue = Uri.encode(out),
)
navigate(
request = NavDeepLinkRequest.Builder
.fromUri(NavDestination.createRoute(route = newRoute).toUri())
.build(),
navOptions = navOptions,
navigatorExtras = navigatorExtras,
)
}
inline fun <reified T> NavBackStackEntry.getData(key: String): T? {
val data = arguments?.getString(key)
return when {
data != null -> Moshi.Builder()
.build()
.adapter(T::class.java)
.fromJson(data)
else -> null
}
}
#Composable
inline fun <reified T> NavBackStackEntry.rememberGetData(key: String): T? {
return remember { getData<T>(key) }
}
Example usage
data class User(
val id: Int,
val name: String,
)
#Composable
fun RootNavGraph() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "${Route.Main}",
) {
composable(
route = "${Route.Main}",
) {
Button(
onClick = {
navController.navigate(
route = "${Route.Profile}",
data = Route.Profile.Key to User(id = 1000, name = "John Doe"),
)
},
content = { Text(text = "Go to Profile") },
}
composable(
route = "${Route.Profile}",
arguments = listOf(
navArgument(name = Route.Profile.Key) { type = NavType.StringType },
),
) { entry ->
val user = entry.rememberGetData<User>(key = Route.Profile.Key)
Text(text = "$user")
}
}
}
you can pass an argument like this
val data = DetailScreenArgument(title = "Sample")
navController.currentBackStackEntry?.savedStateHandle?.apply {
set("detailArgument", data)
}
navController.navigate(Screen.DetailScreen.route)
and get the argument in the destination like this
val detailArgument = navController.previousBackStackEntry?.savedStateHandle?.get<DetailScreenArgument>(
"detailArgument"
)
You can use my own solution: https://github.com/usmonie/compose_navigation_with_parcelable_arguments
With my solution, you will be able to get parcelable values directly into the composable function
I had a similar issue where I had to pass a string that contains slashes, and since they are used as separators for deep link arguments I could not do that. Escaping them didn't seem "clean" to me.
I came up with the following workaround, which can be easily tweaked for your case. I rewrote NavHost, NavController.createGraph and NavGraphBuilder.composable from androidx.navigation.compose as follows:
#Composable
fun NavHost(
navController: NavHostController,
startDestination: Screen,
route: String? = null,
builder: NavGraphBuilder.() -> Unit
) {
NavHost(navController, remember(route, startDestination, builder) {
navController.createGraph(startDestination, route, builder)
})
}
fun NavController.createGraph(
startDestination: Screen,
route: String? = null,
builder: NavGraphBuilder.() -> Unit
) = navigatorProvider.navigation(route?.hashCode() ?: 0, startDestination.hashCode(), builder)
fun NavGraphBuilder.composable(
screen: Screen,
content: #Composable (NavBackStackEntry) -> Unit
) {
addDestination(ComposeNavigator.Destination(provider[ComposeNavigator::class], content).apply {
id = screen.hashCode()
})
}
Where Screen is my destination enum
sealed class Screen {
object Index : Screen()
object Example : Screen()
}
Please note that I removed deep links and arguments since I am not using them. That will still allow me to pass and retrieve arguments manually, and that functionality can be re-added, I simply didn't need it for my case.
Say I want Example to take a string argument path
const val ARG_PATH = "path"
I then initialise the NavHost like so
NavHost(navController, startDestination = Screen.Index) {
composable(Screen.Index) { IndexScreen(::navToExample) }
composable(Screen.Example) { navBackStackEntry ->
navBackStackEntry.arguments?.getString(ARG_PATH)?.let { path ->
ExampleScreen(path, ::navToIndex)
}
}
}
And this is how I navigate to Example passing path
fun navToExample(path: String) {
navController.navigate(Screen.Example.hashCode(), Bundle().apply {
putString(ARG_PATH, path)
})
}
I am sure that this can be improved, but these were my initial thoughts.
To enable deep links, you will need to revert back to using
// composable() and other places
val internalRoute = "android-app://androidx.navigation.compose/$route"
id = internalRoute.hashCode()
Since the nglauber's answer work when going forward and does not when navigating backward and you get a null. I thought maybe at least for the time being we can save the passed argument using remember in our composable and be hopeful that they add the Parcelable argument type to the navigating with the route.
the destination composable target:
composable("yourRout") { backStackEntry ->
backStackEntry.arguments?.let {
val rememberedProject = remember { mutableStateOf<Project?>(null) }
val project =
navController.previousBackStackEntry?.arguments?.getParcelable(
PROJECT_ARGUMENT_KEY
) ?: rememberedProject.value
rememberedProject.value = project
TargetScreen(
project = project ?: throw IllegalArgumentException("parcelable was null"),
)
}
And here's the the source code: to trigger the navigation:
navController.currentBackStackEntry?.arguments =
Bundle().apply {
putParcelable(PROJECT_ARGUMENT_KEY, project)
}
navController.navigate("yourRout")
1 - Add the name of the data sent in the route
const val NEWS_DETAILS = "NEWS_DETAILS/{article}/{name}"
2 - Create dataClassParcelable
#Parcelize
data class ArticlesModel(
#SerializedName("author")
val author: String?,
#SerializedName("content")
val content: String?,
#SerializedName("description")
val description: String?,
#SerializedName("publishedAt")
val publishedAt: String?,
#SerializedName("source")
val source: Strins?,
#SerializedName("title")
val title: String?,
#SerializedName("url")
val url: String?,
#SerializedName("urlToImage")
val urlToImage: String?
):Parcelable
3 - Submit the required data
fun NavController.openNewsDetails(article: ArticlesModel,name:String) {
currentBackStackEntry?.savedStateHandle?.apply{
set(
"article",
article
)
set(
"name",
name
)
}
navigate(ScreenConst.NEWS_DETAILS)
}
4 - Reading information and sending them to a new screen
composable(
route = ScreenConst.NEWS_DETAILS
) { navBackStackEntry ->
val article =
navController.previousBackStackEntry?.savedStateHandle?.get<ArticlesModel("article")
val name=
navController.previousBackStackEntry?.savedStateHandle?.get<String>("name")
if (article==null && name.isNullOrEmpty()) {
return#composable
}
DetailsScreen(
article = article,
name = name
)
}

How to Unit Test a Room Dao Query that Returns a PagingSource From Paging 3

My question is actually quite generic. I want to know how to unit test a Room Dao query that returns a PagingSource From Paging 3.
I have a Room Dao query:
#Query("SELECT * FROM database")
fun getChocolateListData(): PagingSource<Int, Chocolate>
I'm wondering how this query can be unit tested.
What I've tried so far (using in-memory Room database for testing):
#FlowPreview
#Test
fun saveChocolateToDbSavesData() = runBlocking {
val dao: Dao by inject()
val chocolate = Chocolate(
name = "Dove"
)
dao.saveChocolate(chocolate)
val pagingSourceFactory = { dao.getChocolateListData() }
val pagingDataFlow: Flow<PagingData<Chocolate>> = Pager(
config = PagingConfig(
pageSize = 50,
maxSize = 200,
enablePlaceholders = false
),
pagingSourceFactory = pagingSourceFactory
).flow
val chocolateListFlow = pagingDataFlow.testIn(coroutinesTestRule)
Assert.assertEquals(PagingData.from(listOf(chocolate)), chocolateListFlow.emissions[0])
}
This doesn't pass, however:
junit.framework.AssertionFailedError: Expected
:androidx.paging.PagingData#7d6c23a1 Actual
:androidx.paging.PagingData#321123d2
Not sure how to get it right. Any help would be greatly appreciated!
PagingData is wrapper around an internal event stream, you cannot compare it directly and the error you are getting is throwing referential inequality as expected.
Instead you should either query the PagingSource directly to compare the data in LoadResult.Page or you'll need to hook it up to a presenter API such as AsyncPagingDataDiffer or PagingDataAdapter and use .snapshot()
val flow = Pager(..).flow
val adapter = MyPagingDataAdapter()
val job = launch {
flow.collectLatest { adapter.submitData(it) }
}
// Do your asserts here
job.cancel()
if you need a test scope, I recommend runBlockingTest from the kotlinx.coroutines.test library
To query PagingSource directly, it has a single suspending .load() method, so you can simply wrap it in runBlockingTest and assert the result:
#Test
fun test() = runBlockingTest {
val pagingSource = MyPagingSource()
val actual = pagingSource.load(LoadParams.Refresh(...))
assertEquals(actual as? LoadResult.Page)?.data, listOf(...))
}
Based on the answer marked as correct I did my own, is not pretty but at least get the job done if any feedback I would be glad, thanks in advance.
fun <PaginationKey: Any, Model: Any>PagingSource<PaginationKey, Model>.getData(): List<Model> {
val data = mutableListOf<Model>()
val latch = CountDownLatch(1)
val job = CoroutineScope(Dispatchers.Main).launch {
val loadResult: PagingSource.LoadResult<PaginationKey, Model> = this#getData.load(
PagingSource.LoadParams.Refresh(
key = null, loadSize = Int.MAX_VALUE, placeholdersEnabled = false
)
)
when (loadResult) {
is PagingSource.LoadResult.Error -> throw loadResult.throwable
is PagingSource.LoadResult.Page -> data.addAll(loadResult.data)
}
latch.countDown()
}
latch.await()
job.cancel()
return data
}
So in your testing, you can use it like this
val obtainedData = myDao.getSomePagingSource().getData()
assertEquals(expectedData, obtainedData)
WARNING: You are gonna see a rather extended log
WARNING: pageSize on the LegacyPagingSource is not set.
When using legacy DataSource / DataSourceFactory with Paging3, page size...
Just in case you if need to mock PagingSource:
create helper class PagingSourceUtils.kt
Example :
class PagingSourceUtils<T : Any>(
private val data: List<T>
) : PagingSource<Int, T>() {
override fun getRefreshKey(state: PagingState<Int, T>): Int? {
return 0
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> {
return LoadResult.Page(
data = data,
prevKey = null,
nextKey = null
)
}
}
YourTest.kt
#Test
fun `should success get Chocolate `() {
val chocolates = listOf(Chocolate(
name = "Dove"
))
runBlocking {
val tData = PagingSourceUtils(chocolates)
`when`(dao.getChocolateListData()).thenReturn(tData)
val data = ...
val actual = ..
assertEquals(actual, data)
}
}

Categories

Resources