diff --git a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt index a40419114..fb8c6d988 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt @@ -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 -> + StreamBrowseScreen( + streamCluster = screen.streamCluster, + onNavigateUp = ::onNavigateUp, + onNavigateToAppDetails = { packageName -> + backstack.add(Screen.AppDetails(packageName)) + } + ) + } } ) } diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt index 26d463ee3..be2989737 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt @@ -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() } diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt new file mode 100644 index 000000000..9af1a90b3 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt @@ -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 = 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 + ) + } +} diff --git a/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt b/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt index 50b93760b..ccd05d12f 100644 --- a/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt +++ b/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt @@ -114,9 +114,7 @@ abstract class BaseFragment : Fragment() { } fun openStreamBrowseFragment(streamCluster: StreamCluster) { - findNavController().navigate( - MobileNavigationDirections.actionGlobalStreamBrowseFragment(streamCluster) - ) + requireContext().navigate(Screen.StreamBrowse(streamCluster)) } fun openAppMenuSheet(app: MinimalApp) { diff --git a/app/src/main/java/com/aurora/store/view/ui/commons/StreamBrowseFragment.kt b/app/src/main/java/com/aurora/store/view/ui/commons/StreamBrowseFragment.kt deleted file mode 100644 index dfaee3e38..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/commons/StreamBrowseFragment.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * 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 . - * - */ - -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() { - 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") - ) - } - } - } - } -} diff --git a/app/src/main/java/com/aurora/store/viewmodel/browse/StreamBrowseViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/browse/StreamBrowseViewModel.kt index 3ae3ee521..c38526b54 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/browse/StreamBrowseViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/browse/StreamBrowseViewModel.kt @@ -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 = 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.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) } } diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index a3218ea8a..b9ccea686 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -32,9 +32,6 @@ - @@ -120,14 +117,6 @@ app:action="android.intent.action.VIEW" app:uri="play.google.com/store/apps/dev?id={devId}" /> - - -