Feat(Tool): Add tool store

Add tool store, support install tool online.
This commit is contained in:
2024-08-08 17:56:45 +08:00
parent c1879dfdc8
commit c9c0debb2b
35 changed files with 1078 additions and 179 deletions

View File

@@ -22,11 +22,13 @@ import top.fatweb.oxygen.toolbox.monitor.NetworkMonitor
import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor
import top.fatweb.oxygen.toolbox.navigation.STAR_ROUTE
import top.fatweb.oxygen.toolbox.navigation.TOOLS_ROUTE
import top.fatweb.oxygen.toolbox.navigation.TOOL_STORE_ROUTE
import top.fatweb.oxygen.toolbox.navigation.TopLevelDestination
import top.fatweb.oxygen.toolbox.navigation.navigateToAbout
import top.fatweb.oxygen.toolbox.navigation.navigateToLibraries
import top.fatweb.oxygen.toolbox.navigation.navigateToSearch
import top.fatweb.oxygen.toolbox.navigation.navigateToStar
import top.fatweb.oxygen.toolbox.navigation.navigateToToolStore
import top.fatweb.oxygen.toolbox.navigation.navigateToTools
import kotlin.time.Duration.Companion.seconds
@@ -73,6 +75,7 @@ class OxygenAppState(
val currentTopLevelDestination: TopLevelDestination?
@Composable get() = when (currentDestination?.route) {
TOOL_STORE_ROUTE -> TopLevelDestination.TOOL_STORE
TOOLS_ROUTE -> TopLevelDestination.TOOLS
STAR_ROUTE -> TopLevelDestination.STAR
else -> null
@@ -110,6 +113,7 @@ class OxygenAppState(
}
when (topLevelDestination) {
TopLevelDestination.TOOL_STORE -> navController.navigateToToolStore(topLevelNavOptions)
TopLevelDestination.TOOLS -> navController.navigateToTools(topLevelNavOptions)
TopLevelDestination.STAR -> navController.navigateToStar(topLevelNavOptions)
}

View File

@@ -1,12 +1,16 @@
package top.fatweb.oxygen.toolbox.ui.component
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -14,47 +18,88 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
import top.fatweb.oxygen.toolbox.model.tool.Tool
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ToolCard(
modifier: Modifier = Modifier,
tool: Tool,
onClickToolCard: () -> Unit
tool: ToolEntity,
actionIcon: ImageVector? = null,
actionIconContentDescription: String = "",
onAction: () -> Unit = {},
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
Card(
modifier = modifier,
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
onClick = onClickToolCard
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
ToolVer(ver = tool.ver)
ToolHeader(
ver = tool.ver,
actionIcon = actionIcon,
actionIconContentDescription = actionIconContentDescription,
onAction = onAction
)
Spacer(modifier = Modifier.height(16.dp))
ToolIcon(icon = tool.icon)
Spacer(modifier = Modifier.height(16.dp))
ToolInfo(
toolName = tool.name,
toolId = tool.toolId,
toolDesc = tool.description ?: ""
toolDesc = tool.description
)
Spacer(modifier = Modifier.height(16.dp))
AuthorInfo(
avatar = tool.author.avatar,
nickname = tool.author.nickname
avatar = tool.authorAvatar,
nickname = tool.authorNickname
)
}
}
}
@Composable
fun ToolHeader(
modifier: Modifier = Modifier,
ver: String,
actionIcon: ImageVector?,
actionIconContentDescription: String,
onAction: () -> Unit
) {
Row(
modifier = modifier
.height(28.dp)
) {
ToolVer(ver = ver)
Spacer(modifier = Modifier.weight(1f))
actionIcon?.let {
ToolAction(
actionIcon = actionIcon,
actionIconContentDescription = actionIconContentDescription,
onAction = onAction
)
}
}
@@ -66,13 +111,18 @@ fun ToolVer(
ver: String
) {
Card(
modifier = modifier,
modifier = modifier
.fillMaxHeight(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
) {
Column(
modifier = Modifier
.fillMaxHeight()
.background(color = MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 8.dp, vertical = 4.dp)
.padding(horizontal = 8.dp, vertical = 4.dp),
Arrangement.Center,
Alignment.CenterHorizontally
) {
Text(
style = MaterialTheme.typography.bodyMedium,
@@ -82,6 +132,38 @@ fun ToolVer(
}
}
@Composable
fun ToolAction(
modifier: Modifier = Modifier,
actionIcon: ImageVector,
actionIconContentDescription: String,
onAction: () -> Unit
) {
Card(
modifier = modifier
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp))
.clickable(
onClick = onAction
),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
) {
Box(
modifier = Modifier
.fillMaxHeight()
.background(color = MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 6.dp, vertical = 6.dp)
) {
Icon(
modifier = Modifier,
imageVector = actionIcon,
contentDescription = actionIconContentDescription
)
}
}
}
@Composable
fun ToolIcon(
modifier: Modifier = Modifier,
@@ -105,7 +187,7 @@ fun ToolInfo(
modifier: Modifier = Modifier,
toolName: String,
toolId: String,
toolDesc: String
toolDesc: String?
) {
Column(
modifier = modifier.fillMaxWidth(),
@@ -121,12 +203,14 @@ fun ToolInfo(
style = MaterialTheme.typography.bodyMedium,
text = "ID: $toolId"
)
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
text = "${stringResource(R.string.feature_tools_description)}: $toolDesc"
)
toolDesc?.let {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
text = "${stringResource(R.string.feature_tools_description)}: $it"
)
}
}
}

View File

@@ -0,0 +1,289 @@
package top.fatweb.oxygen.toolbox.ui.tool
import androidx.activity.compose.ReportDrawnWhen
import androidx.compose.animation.core.Ease
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.Orientation
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.icon.Loading
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.ui.component.ToolCard
import top.fatweb.oxygen.toolbox.ui.component.scrollbar.DraggableScrollbar
import top.fatweb.oxygen.toolbox.ui.component.scrollbar.rememberDraggableScroller
import top.fatweb.oxygen.toolbox.ui.component.scrollbar.scrollbarState
@Composable
internal fun ToolStoreRoute(
modifier: Modifier = Modifier,
viewModel: ToolStoreViewModel = hiltViewModel(),
onNavigateToToolView: (username: String, toolId: String) -> Unit,
) {
val toolStorePagingItems = viewModel.storeData.collectAsLazyPagingItems()
val installInfo by viewModel.installInfo.collectAsState()
ToolStoreScreen(
modifier = modifier,
onNavigateToToolView = onNavigateToToolView,
toolStorePagingItems = toolStorePagingItems,
onChangeInstallStatus = viewModel::changeInstallStatus,
onInstallTool = viewModel::installTool,
installInfo = installInfo
)
}
@Composable
internal fun ToolStoreScreen(
modifier: Modifier = Modifier,
onNavigateToToolView: (username: String, toolId: String) -> Unit,
toolStorePagingItems: LazyPagingItems<ToolEntity>,
onChangeInstallStatus: (installStatus: ToolStoreUiState.Status, username: String?, toolId: String?) -> Unit,
onInstallTool: () -> Unit,
installInfo: ToolStoreUiState.InstallInfo
) {
val isToolLoading =
toolStorePagingItems.loadState.refresh is LoadState.Loading
|| toolStorePagingItems.loadState.append is LoadState.Loading
ReportDrawnWhen { !isToolLoading }
val itemsAvailable = toolStorePagingItems.itemCount
val state = rememberLazyStaggeredGridState()
val scrollbarState = state.scrollbarState(itemsAvailable = itemsAvailable)
val infiniteTransition = rememberInfiniteTransition(label = "infiniteTransition")
Box(
modifier.fillMaxSize()
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalItemSpacing = 24.dp,
state = state
) {
toolsPanel(
toolStorePagingItems = toolStorePagingItems,
onAction = { username, toolId ->
onChangeInstallStatus(
ToolStoreUiState.Status.Pending,
username,
toolId
)
},
onClick = onNavigateToToolView
)
item(span = StaggeredGridItemSpan.FullLine) {
Spacer(modifier = Modifier.height(8.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
if (toolStorePagingItems.loadState.refresh is LoadState.Loading || toolStorePagingItems.loadState.append is LoadState.Loading) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val angle by infiniteTransition.animateFloat(
initialValue = 0F,
targetValue = 360F,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = Ease),
), label = "angle"
)
Icon(
modifier = Modifier
.size(32.dp)
.graphicsLayer { rotationZ = angle },
imageVector = OxygenIcons.Loading,
contentDescription = ""
)
}
}
state.DraggableScrollbar(
modifier = Modifier
.fillMaxHeight()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(horizontal = 2.dp)
.align(Alignment.CenterEnd),
state = scrollbarState, orientation = Orientation.Vertical,
onThumbMoved = state.rememberDraggableScroller(itemsAvailable = itemsAvailable)
)
}
if (installInfo.status != ToolStoreUiState.Status.None) {
Box(
modifier = Modifier.fillMaxSize()
) {
AlertDialog(
onDismissRequest = {
if (installInfo.status == ToolStoreUiState.Status.Pending) {
onChangeInstallStatus(ToolStoreUiState.Status.None, null, null)
}
},
title = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = when (installInfo.status) {
ToolStoreUiState.Status.Success -> OxygenIcons.Success
ToolStoreUiState.Status.Fail -> OxygenIcons.Error
else -> OxygenIcons.Info
},
contentDescription = stringResource(R.string.core_install)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(
when (installInfo.status) {
ToolStoreUiState.Status.Success -> R.string.feature_store_install_success
ToolStoreUiState.Status.Fail -> R.string.feature_store_install_fail
else -> R.string.feature_store_install_tool
}
)
)
}
},
text = {
Column(
modifier = Modifier
.width(360.dp)
.padding(vertical = 16.dp)
) {
when (installInfo.status) {
ToolStoreUiState.Status.Pending ->
Text(
text = stringResource(
R.string.feature_store_ask_install,
installInfo.username,
installInfo.toolId
)
)
ToolStoreUiState.Status.Installing ->
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(text = stringResource(R.string.core_installing))
}
ToolStoreUiState.Status.Success ->
Text(text = stringResource(R.string.feature_store_install_success_info))
ToolStoreUiState.Status.Fail ->
Text(text = stringResource(R.string.feature_store_install_fail_info))
ToolStoreUiState.Status.None -> Unit
}
}
},
dismissButton = {
if (installInfo.status == ToolStoreUiState.Status.Pending) {
TextButton(onClick = {
onChangeInstallStatus(ToolStoreUiState.Status.None, null, null)
}) {
Text(text = stringResource(R.string.core_cancel))
}
}
},
confirmButton = {
when (installInfo.status) {
ToolStoreUiState.Status.Pending ->
TextButton(onClick = onInstallTool) {
Text(text = stringResource(R.string.core_install))
}
ToolStoreUiState.Status.Success,
ToolStoreUiState.Status.Fail ->
TextButton(onClick = {
onChangeInstallStatus(ToolStoreUiState.Status.None, null, null)
}) {
Text(
text = stringResource(
if (installInfo.status == ToolStoreUiState.Status.Success) R.string.core_ok
else R.string.core_close
)
)
}
ToolStoreUiState.Status.None,
ToolStoreUiState.Status.Installing -> Unit
}
}
)
}
}
}
private fun LazyStaggeredGridScope.toolsPanel(
toolStorePagingItems: LazyPagingItems<ToolEntity>,
onAction: (username: String, toolId: String) -> Unit,
onClick: (username: String, toolId: String) -> Unit
) {
items(
items = toolStorePagingItems.itemSnapshotList,
key = { it!!.id },
) {
ToolCard(
tool = it!!,
actionIcon = OxygenIcons.Download,
actionIconContentDescription = stringResource(R.string.core_install),
onAction = { onAction(it.authorUsername, it.toolId) },
onClick = { onClick(it.authorUsername, it.toolId) },
onLongClick = { onClick(it.authorUsername, it.toolId) },
)
}
}

View File

@@ -0,0 +1,88 @@
package top.fatweb.oxygen.toolbox.ui.tool
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import top.fatweb.oxygen.toolbox.model.Result
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.repository.tool.StoreRepository
import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
import javax.inject.Inject
@HiltViewModel
class ToolStoreViewModel @Inject constructor(
private val storeRepository: StoreRepository,
private val toolRepository: ToolRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val searchValue = savedStateHandle.getStateFlow(SEARCH_VALUE, "")
private val currentPage = savedStateHandle.getStateFlow(CURRENT_PAGE, 1)
val installInfo = savedStateHandle.getStateFlow(
INSTALL_INFO, ToolStoreUiState.InstallInfo()
)
@OptIn(ExperimentalCoroutinesApi::class)
val storeData: Flow<PagingData<ToolEntity>> = combine(
searchValue, currentPage, ::Pair
).flatMapLatest { (searchValue, currentPage) ->
storeRepository.getStore(searchValue, currentPage).cachedIn(viewModelScope)
}
fun changeInstallStatus(
installStatus: ToolStoreUiState.Status, username: String?, toolId: String?
) {
savedStateHandle[INSTALL_INFO] =
ToolStoreUiState.InstallInfo(installStatus, username ?: "Unknown", toolId ?: "Unknown")
}
fun installTool() {
viewModelScope.launch {
val (_, username, toolId) = installInfo.value
storeRepository.detail(username, toolId).collect {
when (it) {
Result.Loading -> savedStateHandle[INSTALL_INFO] =
ToolStoreUiState.InstallInfo(ToolStoreUiState.Status.Installing)
is Result.Error, is Result.Fail -> savedStateHandle[INSTALL_INFO] =
ToolStoreUiState.InstallInfo(ToolStoreUiState.Status.Fail)
is Result.Success -> {
toolRepository.saveTool(it.data)
savedStateHandle[INSTALL_INFO] =
ToolStoreUiState.InstallInfo(ToolStoreUiState.Status.Success)
}
}
}
}
}
}
@Parcelize
data class ToolStoreUiState(
val installInfo: InstallInfo
) : Parcelable {
@Parcelize
data class InstallInfo(
var status: Status = Status.None,
var username: String = "Unknown",
var toolId: String = "Unknown"
) : Parcelable
enum class Status {
None, Pending, Installing, Success, Fail
}
}
private const val SEARCH_VALUE = "searchValue"
private const val CURRENT_PAGE = "currentPage"
private const val INSTALL_INFO = "installInfo"

View File

@@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import top.fatweb.oxygen.toolbox.model.Result
import top.fatweb.oxygen.toolbox.navigation.ToolViewArgs
import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
import top.fatweb.oxygen.toolbox.repository.tool.StoreRepository
import top.fatweb.oxygen.toolbox.util.decodeToStringWithZip
import javax.inject.Inject
import kotlin.io.encoding.Base64
@@ -22,7 +22,7 @@ import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class ToolViewScreenViewModel @Inject constructor(
toolRepository: ToolRepository,
storeRepository: StoreRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val toolViewArgs = ToolViewArgs(savedStateHandle)
@@ -32,7 +32,7 @@ class ToolViewScreenViewModel @Inject constructor(
val toolViewUiState: StateFlow<ToolViewUiState> = toolViewUiState(
username = username,
toolId = toolId,
toolRepository = toolRepository
storeRepository = storeRepository
)
.stateIn(
scope = viewModelScope,
@@ -44,13 +44,13 @@ class ToolViewScreenViewModel @Inject constructor(
private fun toolViewUiState(
username: String,
toolId: String,
toolRepository: ToolRepository
storeRepository: StoreRepository
): Flow<ToolViewUiState> {
val result = toolRepository.detail(
val result = storeRepository.detail(
username = username,
toolId = toolId
)
val toolViewTemplate = toolRepository.toolViewTemplate
val toolViewTemplate = storeRepository.toolViewTemplate
return combine(result, toolViewTemplate, ::Pair).map { (result, toolViewTemplate) ->
when (result) {
@@ -87,7 +87,7 @@ sealed interface ToolViewUiState {
}
@OptIn(ExperimentalEncodingApi::class)
fun processHtml(toolViewTemplate: String, distBase64: String, baseBase64: String): String {
private fun processHtml(toolViewTemplate: String, distBase64: String, baseBase64: String): String {
val dist = Base64.decodeToStringWithZip(distBase64)
val base = Base64.decodeToStringWithZip(baseBase64)

View File

@@ -1,22 +0,0 @@
package top.fatweb.oxygen.toolbox.ui.tool
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.paging.compose.LazyPagingItems
import top.fatweb.oxygen.toolbox.model.tool.Tool
import top.fatweb.oxygen.toolbox.ui.component.ToolCard
fun LazyStaggeredGridScope.toolsPanel(
toolStorePagingItems: LazyPagingItems<Tool>,
onClickToolCard: (username: String, toolId: String) -> Unit
) {
items(
items = toolStorePagingItems.itemSnapshotList,
key = { it!!.id },
) {
ToolCard(
tool = it!!,
onClickToolCard = {onClickToolCard(it.author.username, it.toolId)}
)
}
}

View File

@@ -23,25 +23,29 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.icon.Loading
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
import top.fatweb.oxygen.toolbox.model.tool.Tool
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.ui.component.ToolCard
import top.fatweb.oxygen.toolbox.ui.component.scrollbar.DraggableScrollbar
import top.fatweb.oxygen.toolbox.ui.component.scrollbar.rememberDraggableScroller
import top.fatweb.oxygen.toolbox.ui.component.scrollbar.scrollbarState
@@ -51,14 +55,15 @@ internal fun ToolsRoute(
modifier: Modifier = Modifier,
viewModel: ToolsScreenViewModel = hiltViewModel(),
onNavigateToToolView: (username: String, toolId: String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean
onNavigateToToolStore: () -> Unit
) {
val toolStorePagingItems = viewModel.storeData.collectAsLazyPagingItems()
val toolsScreenUiStateState by viewModel.toolsScreenUiState.collectAsStateWithLifecycle()
ToolsScreen(
modifier = modifier,
onNavigateToToolView = onNavigateToToolView,
toolStorePagingItems = toolStorePagingItems
onNavigateToToolStore = onNavigateToToolStore,
toolsScreenUiState = toolsScreenUiStateState
)
}
@@ -66,15 +71,12 @@ internal fun ToolsRoute(
internal fun ToolsScreen(
modifier: Modifier = Modifier,
onNavigateToToolView: (username: String, toolId: String) -> Unit,
toolStorePagingItems: LazyPagingItems<Tool>
onNavigateToToolStore: () -> Unit,
toolsScreenUiState: ToolsScreenUiState
) {
val isToolLoading =
toolStorePagingItems.loadState.refresh is LoadState.Loading
|| toolStorePagingItems.loadState.append is LoadState.Loading
ReportDrawnWhen { toolsScreenUiState !is ToolsScreenUiState.Loading }
ReportDrawnWhen { !isToolLoading }
val itemsAvailable = toolStorePagingItems.itemCount
val itemsAvailable = howManyTools(toolsScreenUiState)
val state = rememberLazyStaggeredGridState()
val scrollbarState = state.scrollbarState(itemsAvailable = itemsAvailable)
@@ -84,45 +86,61 @@ internal fun ToolsScreen(
Box(
modifier.fillMaxSize()
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalItemSpacing = 24.dp,
state = state
) {
toolsPanel(
toolStorePagingItems = toolStorePagingItems,
onClickToolCard = onNavigateToToolView
)
item(span = StaggeredGridItemSpan.FullLine) {
Spacer(modifier = Modifier.height(8.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
when (toolsScreenUiState) {
ToolsScreenUiState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val angle by infiniteTransition.animateFloat(
initialValue = 0F,
targetValue = 360F,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = Ease),
), label = "angle"
)
Icon(
modifier = Modifier
.size(32.dp)
.graphicsLayer { rotationZ = angle },
imageVector = OxygenIcons.Loading,
contentDescription = ""
)
}
}
}
ToolsScreenUiState.Nothing -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = stringResource(R.string.feature_tools_no_tools_installed))
TextButton(onClick = onNavigateToToolStore) {
Text(text = stringResource(R.string.feature_tools_go_to_store))
}
}
}
is ToolsScreenUiState.Success -> {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalItemSpacing = 24.dp,
state = state
) {
if (toolStorePagingItems.loadState.refresh is LoadState.Loading || toolStorePagingItems.loadState.append is LoadState.Loading) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val angle by infiniteTransition.animateFloat(
initialValue = 0F,
targetValue = 360F,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = Ease),
), label = "angle"
)
Icon(
modifier = Modifier
.size(32.dp)
.graphicsLayer { rotationZ = angle },
imageVector = OxygenIcons.Loading,
contentDescription = ""
)
toolsPanel(
toolItems = toolsScreenUiState.tools,
onClickToolCard = onNavigateToToolView
)
item(span = StaggeredGridItemSpan.FullLine) {
Spacer(modifier = Modifier.height(8.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
}
@@ -136,4 +154,27 @@ internal fun ToolsScreen(
onThumbMoved = state.rememberDraggableScroller(itemsAvailable = itemsAvailable)
)
}
}
}
private fun LazyStaggeredGridScope.toolsPanel(
toolItems: List<ToolEntity>,
onClickToolCard: (username: String, toolId: String) -> Unit
) {
items(
items = toolItems,
key = { it.id },
) {
ToolCard(
tool = it,
onClick = {onClickToolCard(it.authorUsername, it.toolId)},
onLongClick = {onClickToolCard(it.authorUsername, it.toolId)}
)
}
}
@Composable
private fun howManyTools(toolsScreenUiState: ToolsScreenUiState) =
when (toolsScreenUiState) {
ToolsScreenUiState.Loading, ToolsScreenUiState.Nothing -> 0
is ToolsScreenUiState.Success -> toolsScreenUiState.tools.size
}

View File

@@ -3,40 +3,46 @@ package top.fatweb.oxygen.toolbox.ui.tool
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import top.fatweb.oxygen.toolbox.model.Page
import top.fatweb.oxygen.toolbox.model.tool.Tool
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.repository.tool.StoreRepository
import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class ToolsScreenViewModel @Inject constructor(
private val toolRepository: ToolRepository,
private val storeRepository: StoreRepository,
toolRepository: ToolRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val searchValue = savedStateHandle.getStateFlow(SEARCH_VALUE, "")
private val currentPage = savedStateHandle.getStateFlow(CURRENT_PAGE, 1)
@OptIn(ExperimentalCoroutinesApi::class)
val storeData: Flow<PagingData<Tool>> = combine(
searchValue,
currentPage,
::Pair
).flatMapLatest { (searchValue, currentPage) ->
toolRepository.getStore(searchValue, currentPage).cachedIn(viewModelScope)
}
val toolsScreenUiState: StateFlow<ToolsScreenUiState> =
toolRepository.getAllToolsStream()
.map {
if (it.isEmpty()) {
ToolsScreenUiState.Nothing
} else {
ToolsScreenUiState.Success(it)
}
}
.stateIn(
scope = viewModelScope,
initialValue = ToolsScreenUiState.Loading,
started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds)
)
}
sealed interface ToolsScreenUiState {
data object Loading : ToolsScreenUiState
data class Success(val tools: Page<Tool>) : ToolsScreenUiState
data object Nothing : ToolsScreenUiState
data class Success(val tools: List<ToolEntity>) : ToolsScreenUiState
}
private const val SEARCH_VALUE = "searchValue"
private const val CURRENT_PAGE = "currentPage"