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.blacklist.BlacklistScreen
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.dev.DevProfileScreen
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 androidx.navigation3.runtime.NavKey
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.store.data.model.PermissionType
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@@ -63,4 +64,7 @@ sealed class Screen : NavKey, Parcelable {
@Serializable
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) {
findNavController().navigate(
MobileNavigationDirections.actionGlobalStreamBrowseFragment(streamCluster)
)
requireContext().navigate(Screen.StreamBrowse(streamCluster))
}
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
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.aurora.extensions.TAG
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamCluster
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 javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@HiltViewModel
class StreamBrowseViewModel @Inject constructor(
@HiltViewModel(assistedFactory = StreamBrowseViewModel.Factory::class)
class StreamBrowseViewModel @AssistedInject constructor(
@Assisted private val streamCluster: StreamCluster,
private val streamHelper: WebStreamHelper
) : ViewModel() {
val liveData: MutableLiveData<StreamCluster> = MutableLiveData()
private lateinit var streamCluster: StreamCluster
fun seedCluster(cluster: StreamCluster) {
streamCluster = cluster
liveData.postValue(streamCluster)
@AssistedFactory
interface Factory {
fun create(streamCluster: StreamCluster): StreamBrowseViewModel
}
fun nextCluster() {
viewModelScope.launch(Dispatchers.IO) {
private val _apps = MutableStateFlow<PagingData<App>>(PagingData.empty())
val apps = _apps.asStateFlow()
init {
fetchApps()
}
private fun fetchApps() {
var nextPageUrl: String = streamCluster.clusterNextPageUrl
manualPager { page ->
try {
if (streamCluster.hasNext()) {
val next = streamHelper.nextStreamCluster(streamCluster.clusterNextPageUrl)
when (page) {
1 -> streamCluster.clusterAppList
streamCluster = streamCluster.copy(
clusterNextPageUrl = next.clusterNextPageUrl,
clusterAppList = streamCluster.clusterAppList + next.clusterAppList
)
liveData.postValue(streamCluster)
} else {
Log.i(TAG, "End of Cluster")
postClusterEnd()
else -> {
if (nextPageUrl.isNotBlank()) {
streamHelper.nextStreamCluster(nextPageUrl).also {
nextPageUrl = it.clusterNextPageUrl
}.clusterAppList
} else {
emptyList()
}
}
}
} catch (_: Exception) {
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch apps for $page: $nextPageUrl", exception)
emptyList()
}
}
}
fun postClusterEnd() {
streamCluster = streamCluster.copy(
clusterNextPageUrl = ""
)
liveData.postValue(streamCluster)
}.flow.distinctUntilChanged()
.cachedIn(viewModelScope)
.onEach { _apps.value = it }
.launchIn(viewModelScope)
}
}

View File

@@ -32,9 +32,6 @@
<action
android:id="@+id/action_global_devProfileFragment"
app:destination="@id/devProfileFragment" />
<action
android:id="@+id/action_global_streamBrowseFragment"
app:destination="@id/streamBrowseFragment" />
<action
android:id="@+id/action_global_appMenuSheet"
app:destination="@id/appMenuSheet" />
@@ -120,14 +117,6 @@
app:action="android.intent.action.VIEW"
app:uri="play.google.com/store/apps/dev?id={devId}" />
</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
android:id="@+id/splashFragment"
android:name="com.aurora.store.view.ui.splash.SplashFragment"