compose: commons: Migrate stream browse fragment to compose

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2026-04-14 11:43:22 +08:00
parent b1386868c7
commit 40471c15a9
7 changed files with 186 additions and 145 deletions

View File

@@ -22,6 +22,7 @@ import com.aurora.store.compose.ui.about.AboutScreen
import com.aurora.store.compose.ui.accounts.AccountsScreen import com.aurora.store.compose.ui.accounts.AccountsScreen
import com.aurora.store.compose.ui.blacklist.BlacklistScreen import com.aurora.store.compose.ui.blacklist.BlacklistScreen
import com.aurora.store.compose.ui.commons.PermissionRationaleScreen import com.aurora.store.compose.ui.commons.PermissionRationaleScreen
import com.aurora.store.compose.ui.commons.StreamBrowseScreen
import com.aurora.store.compose.ui.details.AppDetailsScreen import com.aurora.store.compose.ui.details.AppDetailsScreen
import com.aurora.store.compose.ui.dev.DevProfileScreen import com.aurora.store.compose.ui.dev.DevProfileScreen
import com.aurora.store.compose.ui.dispenser.DispenserScreen import com.aurora.store.compose.ui.dispenser.DispenserScreen
@@ -155,6 +156,16 @@ fun NavDisplay(startDestination: NavKey) {
} }
) )
} }
entry<Screen.StreamBrowse> { screen ->
StreamBrowseScreen(
streamCluster = screen.streamCluster,
onNavigateUp = ::onNavigateUp,
onNavigateToAppDetails = { packageName ->
backstack.add(Screen.AppDetails(packageName))
}
)
}
} }
) )
} }

View File

@@ -7,6 +7,7 @@ package com.aurora.store.compose.navigation
import android.os.Parcelable import android.os.Parcelable
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.store.data.model.PermissionType import com.aurora.store.data.model.PermissionType
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -63,4 +64,7 @@ sealed class Screen : NavKey, Parcelable {
@Serializable @Serializable
data object Installed : Screen() data object Installed : Screen()
@Serializable
data class StreamBrowse(val streamCluster: StreamCluster) : Screen()
} }

View File

@@ -0,0 +1,123 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.commons
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.aurora.extensions.emptyPagingItems
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.store.R
import com.aurora.store.compose.composable.ContainedLoadingIndicator
import com.aurora.store.compose.composable.Error
import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.composable.app.LargeAppListItem
import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.compose.preview.PreviewTemplate
import com.aurora.store.viewmodel.browse.StreamBrowseViewModel
import kotlin.random.Random
import kotlin.uuid.Uuid
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
fun StreamBrowseScreen(
streamCluster: StreamCluster,
onNavigateUp: () -> Unit,
onNavigateToAppDetails: (packageName: String) -> Unit,
viewModel: StreamBrowseViewModel = hiltViewModel(
creationCallback = { factory: StreamBrowseViewModel.Factory ->
factory.create(streamCluster)
}
)
) {
val apps = viewModel.apps.collectAsLazyPagingItems()
ScreenContent(
title = streamCluster.clusterTitle,
apps = apps,
onNavigateUp = onNavigateUp,
onNavigateToAppDetails = onNavigateToAppDetails
)
}
@Composable
private fun ScreenContent(
title: String = String(),
apps: LazyPagingItems<App> = emptyPagingItems(),
onNavigateToAppDetails: (packageName: String) -> Unit = {},
onNavigateUp: () -> Unit = {}
) {
Scaffold(
topBar = {
TopAppBar(title = title, onNavigateUp = onNavigateUp)
}
) { paddingValues ->
when (apps.loadState.refresh) {
is LoadState.Loading -> ContainedLoadingIndicator()
is LoadState.Error -> {
Error(
modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer),
message = stringResource(R.string.error)
)
}
else -> {
if (apps.itemCount == 0) {
Error(
modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_disclaimer),
message = stringResource(R.string.no_apps_available)
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
items(
count = apps.itemCount,
key = { Uuid.random().toString() }
) { index ->
apps[index]?.let { app ->
LargeAppListItem(
app = app,
onClick = { onNavigateToAppDetails(app.packageName) }
)
}
}
}
}
}
}
}
}
@Preview
@Composable
private fun StreamBrowseScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
PreviewTemplate {
val apps = List(10) { app.copy(id = Random.nextInt()) }
val pagedApps = MutableStateFlow(PagingData.from(apps)).collectAsLazyPagingItems()
ScreenContent(
apps = pagedApps
)
}
}

View File

@@ -114,9 +114,7 @@ abstract class BaseFragment<ViewBindingType : ViewBinding> : Fragment() {
} }
fun openStreamBrowseFragment(streamCluster: StreamCluster) { fun openStreamBrowseFragment(streamCluster: StreamCluster) {
findNavController().navigate( requireContext().navigate(Screen.StreamBrowse(streamCluster))
MobileNavigationDirections.actionGlobalStreamBrowseFragment(streamCluster)
)
} }
fun openAppMenuSheet(app: MinimalApp) { fun openAppMenuSheet(app: MinimalApp) {

View File

@@ -1,96 +0,0 @@
/*
* Aurora Store
* Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
*
* Aurora Store is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* Aurora Store is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.aurora.store.view.ui.commons
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.store.databinding.FragmentGenericWithToolbarBinding
import com.aurora.store.view.custom.recycler.EndlessRecyclerOnScrollListener
import com.aurora.store.view.epoxy.views.AppProgressViewModel_
import com.aurora.store.view.epoxy.views.app.AppListViewModel_
import com.aurora.store.view.epoxy.views.shimmer.AppListViewShimmerModel_
import com.aurora.store.viewmodel.browse.StreamBrowseViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class StreamBrowseFragment : BaseFragment<FragmentGenericWithToolbarBinding>() {
private val args: StreamBrowseFragmentArgs by navArgs()
private val viewModel: StreamBrowseViewModel by viewModels()
private lateinit var streamCluster: StreamCluster
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
streamCluster = args.cluster
// Toolbar
binding.toolbar.apply {
title = streamCluster.clusterTitle
setNavigationOnClickListener { findNavController().navigateUp() }
}
binding.recycler.addOnScrollListener(object :
EndlessRecyclerOnScrollListener(visibleThreshold = 4) {
override fun onLoadMore(currentPage: Int) {
viewModel.nextCluster()
}
})
viewModel.seedCluster(streamCluster)
viewModel.liveData.observe(viewLifecycleOwner) {
updateController(it)
}
}
private fun updateController(streamCluster: StreamCluster?) {
binding.recycler.withModels {
setFilterDuplicates(true)
if (streamCluster == null) {
for (i in 1..6) {
add(
AppListViewShimmerModel_()
.id(i)
)
}
} else {
streamCluster.clusterAppList.forEach {
add(
AppListViewModel_()
.id(it.packageName.hashCode())
.app(it)
.click { _ -> openDetailsFragment(it.packageName) }
)
}
if (streamCluster.hasNext()) {
add(
AppProgressViewModel_()
.id("progress")
)
}
}
}
}
}

View File

@@ -20,56 +20,68 @@
package com.aurora.store.viewmodel.browse package com.aurora.store.viewmodel.browse
import android.util.Log import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.aurora.extensions.TAG import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.helpers.web.WebStreamHelper import com.aurora.gplayapi.helpers.web.WebStreamHelper
import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@HiltViewModel @HiltViewModel(assistedFactory = StreamBrowseViewModel.Factory::class)
class StreamBrowseViewModel @Inject constructor( class StreamBrowseViewModel @AssistedInject constructor(
@Assisted private val streamCluster: StreamCluster,
private val streamHelper: WebStreamHelper private val streamHelper: WebStreamHelper
) : ViewModel() { ) : ViewModel() {
val liveData: MutableLiveData<StreamCluster> = MutableLiveData() @AssistedFactory
interface Factory {
private lateinit var streamCluster: StreamCluster fun create(streamCluster: StreamCluster): StreamBrowseViewModel
fun seedCluster(cluster: StreamCluster) {
streamCluster = cluster
liveData.postValue(streamCluster)
} }
fun nextCluster() { private val _apps = MutableStateFlow<PagingData<App>>(PagingData.empty())
viewModelScope.launch(Dispatchers.IO) { val apps = _apps.asStateFlow()
init {
fetchApps()
}
private fun fetchApps() {
var nextPageUrl: String = streamCluster.clusterNextPageUrl
manualPager { page ->
try { try {
if (streamCluster.hasNext()) { when (page) {
val next = streamHelper.nextStreamCluster(streamCluster.clusterNextPageUrl) 1 -> streamCluster.clusterAppList
streamCluster = streamCluster.copy( else -> {
clusterNextPageUrl = next.clusterNextPageUrl, if (nextPageUrl.isNotBlank()) {
clusterAppList = streamCluster.clusterAppList + next.clusterAppList streamHelper.nextStreamCluster(nextPageUrl).also {
) nextPageUrl = it.clusterNextPageUrl
}.clusterAppList
liveData.postValue(streamCluster) } else {
} else { emptyList()
Log.i(TAG, "End of Cluster") }
postClusterEnd() }
} }
} catch (_: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to fetch apps for $page: $nextPageUrl", exception)
emptyList()
} }
} }.flow.distinctUntilChanged()
} .cachedIn(viewModelScope)
.onEach { _apps.value = it }
fun postClusterEnd() { .launchIn(viewModelScope)
streamCluster = streamCluster.copy(
clusterNextPageUrl = ""
)
liveData.postValue(streamCluster)
} }
} }

View File

@@ -32,9 +32,6 @@
<action <action
android:id="@+id/action_global_devProfileFragment" android:id="@+id/action_global_devProfileFragment"
app:destination="@id/devProfileFragment" /> app:destination="@id/devProfileFragment" />
<action
android:id="@+id/action_global_streamBrowseFragment"
app:destination="@id/streamBrowseFragment" />
<action <action
android:id="@+id/action_global_appMenuSheet" android:id="@+id/action_global_appMenuSheet"
app:destination="@id/appMenuSheet" /> app:destination="@id/appMenuSheet" />
@@ -120,14 +117,6 @@
app:action="android.intent.action.VIEW" app:action="android.intent.action.VIEW"
app:uri="play.google.com/store/apps/dev?id={devId}" /> app:uri="play.google.com/store/apps/dev?id={devId}" />
</fragment> </fragment>
<fragment
android:id="@+id/streamBrowseFragment"
android:name="com.aurora.store.view.ui.commons.StreamBrowseFragment"
tools:layout="@layout/fragment_generic_with_toolbar">
<argument
android:name="cluster"
app:argType="com.aurora.gplayapi.data.models.StreamCluster" />
</fragment>
<fragment <fragment
android:id="@+id/splashFragment" android:id="@+id/splashFragment"
android:name="com.aurora.store.view.ui.splash.SplashFragment" android:name="com.aurora.store.view.ui.splash.SplashFragment"