compose: build MainScreen and the apps/games tabs
MainScreen hosts the bottom-nav between Apps / Games / Updates with a HorizontalPager. The Apps/Games host (AppsGamesScreen) carries the For You / Top Charts / Categories sub-tabs implemented as separate pages.
This commit is contained in:
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.aurora.store.compose.ui.apps
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.material3.PrimaryTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import com.aurora.gplayapi.helpers.contracts.StreamContract
|
||||||
|
import com.aurora.gplayapi.helpers.contracts.TopChartsContract
|
||||||
|
import com.aurora.store.R
|
||||||
|
import com.aurora.store.compose.navigation.Destination
|
||||||
|
import com.aurora.store.data.model.MinimalApp
|
||||||
|
import com.aurora.store.util.Preferences
|
||||||
|
import com.aurora.store.viewmodel.category.CategoryViewModel
|
||||||
|
import com.aurora.store.viewmodel.homestream.StreamViewModel
|
||||||
|
import com.aurora.store.viewmodel.topchart.TopChartViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
internal fun category(pageType: Int): StreamContract.Category =
|
||||||
|
if (pageType == 1) StreamContract.Category.GAME else StreamContract.Category.APPLICATION
|
||||||
|
|
||||||
|
private enum class AppsTab(@StringRes val titleRes: Int) {
|
||||||
|
FOR_YOU(R.string.tab_for_you),
|
||||||
|
TOP_CHARTS(R.string.tab_top_charts),
|
||||||
|
CATEGORIES(R.string.tab_categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppsGamesScreen(
|
||||||
|
pageType: Int,
|
||||||
|
streamViewModel: StreamViewModel = hiltViewModel(key = "stream_$pageType"),
|
||||||
|
topChartViewModel: TopChartViewModel = hiltViewModel(key = "topChart_$pageType"),
|
||||||
|
categoryViewModel: CategoryViewModel = hiltViewModel(key = "category_$pageType"),
|
||||||
|
onNavigateTo: (Destination) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val chartType = if (pageType == 1) {
|
||||||
|
TopChartsContract.Type.GAME
|
||||||
|
} else {
|
||||||
|
TopChartsContract.Type.APPLICATION
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
topChartViewModel.getStreamCluster(chartType, TopChartsContract.Chart.TOP_SELLING_FREE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val isForYouEnabled = Preferences.getBoolean(context, Preferences.PREFERENCE_FOR_YOU)
|
||||||
|
val tabs = buildList {
|
||||||
|
if (isForYouEnabled) add(AppsTab.FOR_YOU)
|
||||||
|
add(AppsTab.TOP_CHARTS)
|
||||||
|
add(AppsTab.CATEGORIES)
|
||||||
|
}
|
||||||
|
val pagerState = rememberPagerState { tabs.size }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
PrimaryTabRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
selectedTabIndex = pagerState.currentPage
|
||||||
|
) {
|
||||||
|
tabs.forEachIndexed { index, tab ->
|
||||||
|
Tab(
|
||||||
|
selected = pagerState.currentPage == index,
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
pagerState.animateScrollToPage(index)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = { Text(stringResource(tab.titleRes)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalPager(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = pagerState,
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
userScrollEnabled = false
|
||||||
|
) { page ->
|
||||||
|
when (tabs[page]) {
|
||||||
|
AppsTab.FOR_YOU -> ForYouContent(
|
||||||
|
pageType = pageType,
|
||||||
|
viewModel = streamViewModel,
|
||||||
|
onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) },
|
||||||
|
onAppLongClick = { onNavigateTo(Destination.AppMenu(MinimalApp.fromApp(it))) },
|
||||||
|
onHeaderClick = { onNavigateTo(Destination.StreamBrowse(it)) },
|
||||||
|
onClusterScrolled = { cluster ->
|
||||||
|
streamViewModel.observeCluster(category(pageType), cluster)
|
||||||
|
},
|
||||||
|
onScrolledToEnd = {
|
||||||
|
streamViewModel.observe(category(pageType), StreamContract.Type.HOME)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
AppsTab.TOP_CHARTS -> TopChartsContent(
|
||||||
|
pageType = pageType,
|
||||||
|
viewModel = topChartViewModel,
|
||||||
|
onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
AppsTab.CATEGORIES -> CategoriesContent(
|
||||||
|
pageType = pageType,
|
||||||
|
viewModel = categoryViewModel,
|
||||||
|
onCategoryClick = { onNavigateTo(Destination.CategoryBrowse(it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.aurora.store.compose.ui.apps
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||||
|
import com.aurora.gplayapi.data.models.Category
|
||||||
|
import com.aurora.store.CategoryStash
|
||||||
|
import com.aurora.store.compose.composable.CategoryItem
|
||||||
|
import com.aurora.store.compose.composable.ShimmerCategoryRow
|
||||||
|
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||||
|
import com.aurora.store.data.model.ViewState
|
||||||
|
import com.aurora.store.viewmodel.category.CategoryViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun CategoriesContent(
|
||||||
|
pageType: Int,
|
||||||
|
viewModel: CategoryViewModel,
|
||||||
|
onCategoryClick: (Category) -> Unit
|
||||||
|
) {
|
||||||
|
val categoryType = if (pageType == 1) Category.Type.GAME else Category.Type.APPLICATION
|
||||||
|
val state by viewModel.liveData.observeAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(categoryType) {
|
||||||
|
viewModel.getCategoryList(categoryType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val categories = (state as? ViewState.Success<*>)?.data as? CategoryStash
|
||||||
|
val list = categories?.get(categoryType)
|
||||||
|
|
||||||
|
CategoriesBody(list = list, onCategoryClick = onCategoryClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CategoriesBody(list: List<Category>?, onCategoryClick: (Category) -> Unit = {}) {
|
||||||
|
if (list.isNullOrEmpty()) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(10) { ShimmerCategoryRow() }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(count = list.size, key = { list[it].title }) { index ->
|
||||||
|
CategoryItem(
|
||||||
|
category = list[index],
|
||||||
|
onClick = { onCategoryClick(list[index]) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewWrapper(ThemePreviewProvider::class)
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun CategoriesBodyLoadingPreview() {
|
||||||
|
CategoriesBody(list = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewWrapper(ThemePreviewProvider::class)
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun CategoriesBodyLoadedPreview() {
|
||||||
|
val categories = listOf(
|
||||||
|
"Art & Design",
|
||||||
|
"Auto & Vehicles",
|
||||||
|
"Beauty",
|
||||||
|
"Books & Reference",
|
||||||
|
"Business",
|
||||||
|
"Comics",
|
||||||
|
"Communication",
|
||||||
|
"Dating",
|
||||||
|
"Education",
|
||||||
|
"Entertainment"
|
||||||
|
).map { Category(title = it, imageUrl = "") }
|
||||||
|
CategoriesBody(list = categories)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.aurora.store.compose.ui.apps
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.aurora.gplayapi.data.models.App
|
||||||
|
import com.aurora.gplayapi.data.models.StreamCluster
|
||||||
|
import com.aurora.gplayapi.helpers.contracts.StreamContract
|
||||||
|
import com.aurora.store.HomeStash
|
||||||
|
import com.aurora.store.compose.composable.StreamCarousel
|
||||||
|
import com.aurora.store.compose.composition.observeForced
|
||||||
|
import com.aurora.store.data.model.ViewState
|
||||||
|
import com.aurora.store.viewmodel.homestream.StreamViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ForYouContent(
|
||||||
|
pageType: Int,
|
||||||
|
viewModel: StreamViewModel,
|
||||||
|
onAppClick: (App) -> Unit,
|
||||||
|
onAppLongClick: (App) -> Unit,
|
||||||
|
onHeaderClick: (StreamCluster) -> Unit,
|
||||||
|
onClusterScrolled: (StreamCluster) -> Unit,
|
||||||
|
onScrolledToEnd: () -> Unit
|
||||||
|
) {
|
||||||
|
val category = category(pageType)
|
||||||
|
val state by viewModel.liveData.observeForced()
|
||||||
|
|
||||||
|
LaunchedEffect(category) {
|
||||||
|
viewModel.getStreamBundle(category, StreamContract.Type.HOME)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val streamBundle = (state as? ViewState.Success<*>)?.data as? HomeStash
|
||||||
|
StreamCarousel(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
streamBundle = streamBundle?.get(category),
|
||||||
|
onHeaderClick = onHeaderClick,
|
||||||
|
onAppClick = onAppClick,
|
||||||
|
onAppLongClick = onAppLongClick,
|
||||||
|
onClusterScrolled = onClusterScrolled,
|
||||||
|
onScrolledToEnd = onScrolledToEnd
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.aurora.store.compose.ui.apps
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.SecondaryScrollableTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.dimensionResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||||
|
import com.aurora.gplayapi.data.models.App
|
||||||
|
import com.aurora.gplayapi.data.models.StreamCluster
|
||||||
|
import com.aurora.gplayapi.helpers.contracts.TopChartsContract
|
||||||
|
import com.aurora.store.R
|
||||||
|
import com.aurora.store.compose.composable.EmptyState
|
||||||
|
import com.aurora.store.compose.composable.ShimmerAppRow
|
||||||
|
import com.aurora.store.compose.composable.app.LargeAppListItem
|
||||||
|
import com.aurora.store.compose.composition.collectForced
|
||||||
|
import com.aurora.store.compose.preview.AppPreviewProvider
|
||||||
|
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||||
|
import com.aurora.store.data.model.ViewState
|
||||||
|
import com.aurora.store.data.model.ViewState.Loading.getDataAs
|
||||||
|
import com.aurora.store.viewmodel.topchart.TopChartViewModel
|
||||||
|
|
||||||
|
private const val LOAD_MORE_THRESHOLD = 2
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TopChartsContent(
|
||||||
|
pageType: Int,
|
||||||
|
viewModel: TopChartViewModel,
|
||||||
|
onAppClick: (App) -> Unit
|
||||||
|
) {
|
||||||
|
val charts = listOf(
|
||||||
|
TopChartsContract.Chart.TOP_SELLING_FREE,
|
||||||
|
TopChartsContract.Chart.TOP_GROSSING,
|
||||||
|
TopChartsContract.Chart.MOVERS_SHAKERS,
|
||||||
|
TopChartsContract.Chart.TOP_SELLING_PAID
|
||||||
|
)
|
||||||
|
val chartTitles = listOf(
|
||||||
|
R.string.tab_top_free,
|
||||||
|
R.string.tab_top_grossing,
|
||||||
|
R.string.tab_trending,
|
||||||
|
R.string.tab_top_paid
|
||||||
|
)
|
||||||
|
val chartType =
|
||||||
|
if (pageType == 1) TopChartsContract.Type.GAME else TopChartsContract.Type.APPLICATION
|
||||||
|
var selectedIndex by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
val selectedChart = charts[selectedIndex]
|
||||||
|
val state by viewModel.state.collectForced(ViewState.Loading)
|
||||||
|
val cluster = state.getDataAs<StreamCluster?>()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(selectedIndex) {
|
||||||
|
listState.scrollToItem(0)
|
||||||
|
viewModel.getStreamCluster(chartType, selectedChart)
|
||||||
|
}
|
||||||
|
|
||||||
|
val reachedEnd by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||||
|
val total = listState.layoutInfo.totalItemsCount
|
||||||
|
last >= total - LOAD_MORE_THRESHOLD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(reachedEnd) {
|
||||||
|
if (reachedEnd && cluster?.hasNext() == true) {
|
||||||
|
viewModel.nextCluster(chartType, selectedChart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TopChartsBody(
|
||||||
|
selectedIndex = selectedIndex,
|
||||||
|
chartTitles = chartTitles,
|
||||||
|
state = state,
|
||||||
|
cluster = cluster,
|
||||||
|
listState = listState,
|
||||||
|
onTabSelected = { selectedIndex = it },
|
||||||
|
onRetry = { viewModel.getStreamCluster(chartType, selectedChart) },
|
||||||
|
onAppClick = onAppClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TopChartsBody(
|
||||||
|
selectedIndex: Int,
|
||||||
|
chartTitles: List<Int>,
|
||||||
|
state: ViewState,
|
||||||
|
cluster: StreamCluster?,
|
||||||
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
onTabSelected: (Int) -> Unit = {},
|
||||||
|
onRetry: () -> Unit = {},
|
||||||
|
onAppClick: (App) -> Unit = {}
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
SecondaryScrollableTabRow(
|
||||||
|
selectedTabIndex = selectedIndex,
|
||||||
|
edgePadding = dimensionResource(R.dimen.padding_small)
|
||||||
|
) {
|
||||||
|
chartTitles.forEachIndexed { index, titleRes ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedIndex == index,
|
||||||
|
onClick = { onTabSelected(index) },
|
||||||
|
text = { Text(stringResource(titleRes)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
state is ViewState.Error -> EmptyState(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
icon = R.drawable.ic_apps,
|
||||||
|
message = R.string.no_apps_available,
|
||||||
|
actionLabel = R.string.action_retry,
|
||||||
|
onAction = onRetry
|
||||||
|
)
|
||||||
|
|
||||||
|
cluster != null && cluster.clusterAppList.isEmpty() -> EmptyState(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
icon = R.drawable.ic_apps,
|
||||||
|
message = R.string.no_apps_available
|
||||||
|
)
|
||||||
|
|
||||||
|
cluster != null -> {
|
||||||
|
val apps = cluster.clusterAppList
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
vertical = dimensionResource(R.dimen.padding_small)
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(
|
||||||
|
dimensionResource(R.dimen.margin_xsmall)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
items(count = apps.size, key = { apps[it].id }) { index ->
|
||||||
|
LargeAppListItem(
|
||||||
|
app = apps[index],
|
||||||
|
onClick = { onAppClick(apps[index]) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (cluster.hasNext()) {
|
||||||
|
item(key = "progress") {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(dimensionResource(R.dimen.padding_large)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(vertical = dimensionResource(R.dimen.padding_small)),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_xsmall))
|
||||||
|
) {
|
||||||
|
items(8) { ShimmerAppRow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val previewChartTitles = listOf(
|
||||||
|
R.string.tab_top_free,
|
||||||
|
R.string.tab_top_grossing,
|
||||||
|
R.string.tab_trending,
|
||||||
|
R.string.tab_top_paid
|
||||||
|
)
|
||||||
|
|
||||||
|
@PreviewWrapper(ThemePreviewProvider::class)
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun TopChartsBodyLoadingPreview() {
|
||||||
|
TopChartsBody(
|
||||||
|
selectedIndex = 0,
|
||||||
|
chartTitles = previewChartTitles,
|
||||||
|
state = ViewState.Loading,
|
||||||
|
cluster = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewWrapper(ThemePreviewProvider::class)
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun TopChartsBodyLoadedPreview() {
|
||||||
|
val app = AppPreviewProvider().values.first()
|
||||||
|
val apps = List(5) { i -> app.copy(id = i + 1, packageName = "com.preview.app$i") }
|
||||||
|
val cluster = StreamCluster(clusterAppList = apps)
|
||||||
|
TopChartsBody(
|
||||||
|
selectedIndex = 0,
|
||||||
|
chartTitles = previewChartTitles,
|
||||||
|
state = ViewState.Success(cluster),
|
||||||
|
cluster = cluster
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewWrapper(ThemePreviewProvider::class)
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun TopChartsBodyEmptyPreview() {
|
||||||
|
val cluster = StreamCluster(clusterAppList = emptyList())
|
||||||
|
TopChartsBody(
|
||||||
|
selectedIndex = 1,
|
||||||
|
chartTitles = previewChartTitles,
|
||||||
|
state = ViewState.Success(cluster),
|
||||||
|
cluster = cluster
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewWrapper(ThemePreviewProvider::class)
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun TopChartsBodyErrorPreview() {
|
||||||
|
TopChartsBody(
|
||||||
|
selectedIndex = 0,
|
||||||
|
chartTitles = previewChartTitles,
|
||||||
|
state = ViewState.Error("Network error"),
|
||||||
|
cluster = null
|
||||||
|
)
|
||||||
|
}
|
||||||
240
app/src/main/java/com/aurora/store/compose/ui/main/MainScreen.kt
Normal file
240
app/src/main/java/com/aurora/store/compose/ui/main/MainScreen.kt
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.aurora.store.compose.ui.main
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.BadgedBox
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.aurora.extensions.requiresObbDir
|
||||||
|
import com.aurora.store.MainViewModel
|
||||||
|
import com.aurora.store.R
|
||||||
|
import com.aurora.store.compose.composable.TopAppBar
|
||||||
|
import com.aurora.store.compose.navigation.Destination
|
||||||
|
import com.aurora.store.compose.ui.apps.AppsGamesScreen
|
||||||
|
import com.aurora.store.compose.ui.commons.MoreSheet
|
||||||
|
import com.aurora.store.compose.ui.commons.NetworkSheet
|
||||||
|
import com.aurora.store.compose.ui.sheets.AppMenuSheet
|
||||||
|
import com.aurora.store.compose.ui.updates.UpdatesScreen
|
||||||
|
import com.aurora.store.data.model.MinimalApp
|
||||||
|
import com.aurora.store.data.model.NetworkStatus
|
||||||
|
import com.aurora.store.data.model.PermissionType
|
||||||
|
import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted
|
||||||
|
import com.aurora.store.viewmodel.all.UpdatesViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private enum class MainTab(
|
||||||
|
@StringRes val labelRes: Int,
|
||||||
|
@DrawableRes val iconRes: Int
|
||||||
|
) {
|
||||||
|
APPS(R.string.title_apps, R.drawable.ic_apps),
|
||||||
|
GAMES(R.string.title_games, R.drawable.ic_games),
|
||||||
|
UPDATES(R.string.title_updates, R.drawable.ic_updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
initialTab: Int = 0,
|
||||||
|
mainViewModel: MainViewModel = hiltViewModel(),
|
||||||
|
updatesViewModel: UpdatesViewModel = hiltViewModel(),
|
||||||
|
onNavigateTo: (Destination) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val networkStatus by mainViewModel.networkProvider.status.collectAsStateWithLifecycle(
|
||||||
|
initialValue = NetworkStatus.AVAILABLE
|
||||||
|
)
|
||||||
|
val updates by mainViewModel.updateHelper.updates.collectAsStateWithLifecycle(
|
||||||
|
initialValue = null
|
||||||
|
)
|
||||||
|
val updateCount = updates?.size ?: 0
|
||||||
|
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val pagerState = rememberPagerState(
|
||||||
|
initialPage = initialTab.coerceIn(
|
||||||
|
0,
|
||||||
|
MainTab.entries.size - 1
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
MainTab.entries.size
|
||||||
|
}
|
||||||
|
|
||||||
|
var showMoreSheet by remember { mutableStateOf(false) }
|
||||||
|
var appMenuTarget by remember { mutableStateOf<MinimalApp?>(null) }
|
||||||
|
var showNetworkDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun handleNavigation(destination: Destination) {
|
||||||
|
when (destination) {
|
||||||
|
is Destination.AppMenu -> appMenuTarget = destination.app
|
||||||
|
else -> onNavigateTo(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(networkStatus) {
|
||||||
|
showNetworkDialog = networkStatus == NetworkStatus.UNAVAILABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showNetworkDialog) {
|
||||||
|
NetworkSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMoreSheet) {
|
||||||
|
MoreSheet(
|
||||||
|
onDismiss = { showMoreSheet = false },
|
||||||
|
onNavigateTo = { destination ->
|
||||||
|
showMoreSheet = false
|
||||||
|
onNavigateTo(destination)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
appMenuTarget?.let { app ->
|
||||||
|
AppMenuSheet(app = app, onDismiss = { appMenuTarget = null })
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = stringResource(MainTab.entries[pagerState.currentPage].labelRes),
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { onNavigateTo(Destination.Downloads) }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_download_manager),
|
||||||
|
contentDescription = stringResource(R.string.title_download_manager)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { showMoreSheet = true }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_settings_account),
|
||||||
|
contentDescription = stringResource(R.string.title_more)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = { onNavigateTo(Destination.Search) }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_round_search),
|
||||||
|
contentDescription = stringResource(R.string.action_search)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
NavigationBar {
|
||||||
|
MainTab.entries.forEachIndexed { index, tab ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = pagerState.currentPage == index,
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch { pagerState.animateScrollToPage(index) }
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
if (tab == MainTab.UPDATES && updateCount > 0) {
|
||||||
|
BadgedBox(badge = { Badge { Text("$updateCount") } }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(tab.iconRes),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(tab.iconRes),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(tab.labelRes)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.consumeWindowInsets(paddingValues)
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
userScrollEnabled = false,
|
||||||
|
beyondViewportPageCount = MainTab.entries.size - 1,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) { page ->
|
||||||
|
when (MainTab.entries[page]) {
|
||||||
|
MainTab.APPS -> AppsGamesScreen(
|
||||||
|
pageType = 0,
|
||||||
|
onNavigateTo = ::handleNavigation
|
||||||
|
)
|
||||||
|
MainTab.GAMES -> AppsGamesScreen(
|
||||||
|
pageType = 1,
|
||||||
|
onNavigateTo = ::handleNavigation
|
||||||
|
)
|
||||||
|
MainTab.UPDATES -> UpdatesScreen(
|
||||||
|
viewModel = updatesViewModel,
|
||||||
|
onNavigateTo = ::handleNavigation,
|
||||||
|
onRequestUpdate = { update ->
|
||||||
|
if (update.fileList.requiresObbDir() &&
|
||||||
|
!isGranted(context, PermissionType.STORAGE_MANAGER)
|
||||||
|
) {
|
||||||
|
onNavigateTo(
|
||||||
|
Destination.PermissionRationale(
|
||||||
|
setOf(PermissionType.STORAGE_MANAGER)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
updatesViewModel.download(update)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequestUpdateAll = {
|
||||||
|
val needsObb = updatesViewModel.updates.value?.any {
|
||||||
|
it.fileList.requiresObbDir()
|
||||||
|
} == true
|
||||||
|
if (needsObb && !isGranted(context, PermissionType.STORAGE_MANAGER)) {
|
||||||
|
onNavigateTo(
|
||||||
|
Destination.PermissionRationale(
|
||||||
|
setOf(PermissionType.STORAGE_MANAGER)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
updatesViewModel.downloadAll()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancelUpdate = { packageName ->
|
||||||
|
updatesViewModel.cancelDownload(packageName)
|
||||||
|
},
|
||||||
|
onCancelAll = { updatesViewModel.cancelAll() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user