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