Feat(ToolStarScreen): Support star tool

This commit is contained in:
2024-08-20 15:31:24 +08:00
parent 086b588445
commit da4c58b631
12 changed files with 361 additions and 35 deletions

View File

@@ -31,6 +31,15 @@ interface ToolDao {
"ORDER BY updateTime DESC") "ORDER BY updateTime DESC")
fun selectAllTools(searchValue: String): Flow<List<ToolEntity>> fun selectAllTools(searchValue: String): Flow<List<ToolEntity>>
@Query("SELECT * FROM tools " +
"WHERE isStar = 1 " +
"AND (:searchValue = '' " +
"OR name LIKE '%' || :searchValue || '%' COLLATE NOCASE " +
"OR keywords LIKE '%\"%' || :searchValue || '%\"%' COLLATE NOCASE" +
") " +
"ORDER BY updateTime DESC")
fun selectStarTools(searchValue: String): Flow<List<ToolEntity>>
@Query("SELECT * FROM tools " + @Query("SELECT * FROM tools " +
"WHERE authorUsername = :username " + "WHERE authorUsername = :username " +
"and toolId = :toolId LIMIT 1") "and toolId = :toolId LIMIT 1")

View File

@@ -45,7 +45,9 @@ fun OxygenNavHost(
onBackClick = navController::popBackStack onBackClick = navController::popBackStack
) )
starScreen( starScreen(
isVertical = isVertical isVertical = isVertical,
searchValue = searchValue,
onNavigateToToolView = navController::navigateToToolView
) )
} }
} }

View File

@@ -15,7 +15,9 @@ const val STAR_ROUTE = "star_route"
fun NavController.navigateToStar(navOptions: NavOptions) = navigate(STAR_ROUTE, navOptions) fun NavController.navigateToStar(navOptions: NavOptions) = navigate(STAR_ROUTE, navOptions)
fun NavGraphBuilder.starScreen( fun NavGraphBuilder.starScreen(
isVertical: Boolean isVertical: Boolean,
searchValue: String,
onNavigateToToolView: (username: String, toolId: String, preview: Boolean) -> Unit
) { ) {
composable( composable(
route = STAR_ROUTE, route = STAR_ROUTE,
@@ -38,6 +40,9 @@ fun NavGraphBuilder.starScreen(
} }
} }
) { ) {
StarRoute() StarRoute(
searchValue = searchValue,
onNavigateToToolView = onNavigateToToolView
)
} }
} }

View File

@@ -8,6 +8,8 @@ interface ToolRepository {
fun getAllToolsStream(searchValue: String): Flow<List<ToolEntity>> fun getAllToolsStream(searchValue: String): Flow<List<ToolEntity>>
fun getStarToolsStream(searchValue: String): Flow<List<ToolEntity>>
fun getToolById(id: Long): Flow<ToolEntity?> fun getToolById(id: Long): Flow<ToolEntity?>
fun getToolByUsernameAndToolId(username: String, toolId: String): Flow<ToolEntity?> fun getToolByUsernameAndToolId(username: String, toolId: String): Flow<ToolEntity?>

View File

@@ -17,6 +17,9 @@ class OfflineToolRepository @Inject constructor(
override fun getAllToolsStream(searchValue: String): Flow<List<ToolEntity>> = override fun getAllToolsStream(searchValue: String): Flow<List<ToolEntity>> =
toolDao.selectAllTools(searchValue) toolDao.selectAllTools(searchValue)
override fun getStarToolsStream(searchValue: String): Flow<List<ToolEntity>> =
toolDao.selectStarTools(searchValue)
override fun getToolById(id: Long): Flow<ToolEntity?> = override fun getToolById(id: Long): Flow<ToolEntity?> =
toolDao.selectToolById(id) toolDao.selectToolById(id)

View File

@@ -1,20 +0,0 @@
package top.fatweb.oxygen.toolbox.ui.star
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
internal fun StarRoute(
modifier: Modifier = Modifier
) {
StarScreen(
modifier = modifier
)
}
@Composable
internal fun StarScreen(
modifier: Modifier = Modifier
) {
}

View File

@@ -0,0 +1,237 @@
package top.fatweb.oxygen.toolbox.ui.star
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.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.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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
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.setValue
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.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.ToolEntity
import top.fatweb.oxygen.toolbox.ui.component.DialogClickerRow
import top.fatweb.oxygen.toolbox.ui.component.DialogSectionGroup
import top.fatweb.oxygen.toolbox.ui.component.DialogTitle
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 StarRoute(
modifier: Modifier = Modifier,
viewModel: StarScreenViewModel = hiltViewModel(),
searchValue: String,
onNavigateToToolView: (username: String, toolId: String, preview: Boolean) -> Unit
) {
val starScreenUiState by viewModel.starScreenUiState.collectAsStateWithLifecycle()
LaunchedEffect(searchValue) {
viewModel.onSearchValueChange(searchValue)
}
StarScreen(
modifier = modifier,
starScreenUiState = starScreenUiState,
onNavigateToToolView = onNavigateToToolView,
onUnstar = viewModel::unstar
)
}
@Composable
internal fun StarScreen(
modifier: Modifier = Modifier,
starScreenUiState: StarScreenUiState,
onNavigateToToolView: (username: String, toolId: String, preview: Boolean) -> Unit,
onUnstar: (ToolEntity) -> Unit
) {
ReportDrawnWhen { starScreenUiState !is StarScreenUiState.Loading }
val itemsAvailable = howManyTools(starScreenUiState)
val state = rememberLazyStaggeredGridState()
val scrollbarState = state.scrollbarState(itemsAvailable = itemsAvailable)
val infiniteTransition = rememberInfiniteTransition(label = "infiniteTransition")
var selectedTool by remember { mutableStateOf<ToolEntity?>(null) }
var isShowMenu by remember { mutableStateOf(true) }
Box(
modifier.fillMaxSize()
) {
when (starScreenUiState) {
StarScreenUiState.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 = ""
)
}
}
StarScreenUiState.Nothing -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = stringResource(R.string.feature_star_no_tools_starred))
}
}
is StarScreenUiState.Success -> {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalItemSpacing = 24.dp,
state = state
) {
toolsPanel(
toolItems = starScreenUiState.tools,
onClick = onNavigateToToolView,
onLongClick = {
selectedTool = it
isShowMenu = true
}
)
item(span = StaggeredGridItemSpan.FullLine) {
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
}
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 (isShowMenu && selectedTool != null) {
ToolMenu(
onDismiss = { isShowMenu = false },
selectedTool = selectedTool!!,
onUnstar = {
isShowMenu = false
onUnstar(selectedTool!!)
}
)
}
}
private fun LazyStaggeredGridScope.toolsPanel(
toolItems: List<ToolEntity>,
onClick: (username: String, toolId: String, preview: Boolean) -> Unit,
onLongClick: (ToolEntity) -> Unit
) {
items(
items = toolItems,
key = { it.id }
) {
ToolCard(
tool = it,
actionIcon = if (it.isStar) OxygenIcons.Star else null,
actionIconContentDescription = stringResource(R.string.core_star),
onClick = { onClick(it.authorUsername, it.toolId, false) },
onLongClick = { onLongClick(it) },
onAction = { onClick(it.authorUsername, it.toolId, false) }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ToolMenu(
modifier: Modifier = Modifier,
onDismiss: () -> Unit,
selectedTool: ToolEntity,
onUnstar: () -> Unit
) {
ModalBottomSheet(onDismissRequest = onDismiss, dragHandle = {}) {
Column(
modifier = modifier.padding(16.dp)
){
DialogTitle(text = selectedTool.name)
HorizontalDivider()
Spacer(modifier = Modifier.height(4.dp))
DialogSectionGroup {
DialogClickerRow(
icon = OxygenIcons.StarBorder,
text = stringResource(R.string.core_unstar),
onClick = onUnstar
)
}
}
}
}
@Composable
private fun howManyTools(starScreenUiState: StarScreenUiState) =
when (starScreenUiState) {
StarScreenUiState.Loading, StarScreenUiState.Nothing -> 0
is StarScreenUiState.Success -> starScreenUiState.tools.size
}

View File

@@ -0,0 +1,59 @@
package top.fatweb.oxygen.toolbox.ui.star
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class StarScreenViewModel @Inject constructor(
private val toolRepository: ToolRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val searchValue = savedStateHandle.getStateFlow(SEARCH_VALUE, "")
@OptIn(ExperimentalCoroutinesApi::class)
val starScreenUiState: StateFlow<StarScreenUiState> =
searchValue.flatMapLatest { searchValue ->
toolRepository.getStarToolsStream(searchValue).map {
if (it.isEmpty()) {
StarScreenUiState.Nothing
} else {
StarScreenUiState.Success(it)
}
}
}.stateIn(
scope = viewModelScope,
initialValue = StarScreenUiState.Loading,
started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds)
)
fun onSearchValueChange(value: String) {
savedStateHandle[SEARCH_VALUE] = value
}
fun unstar(tool: ToolEntity) {
viewModelScope.launch {
toolRepository.updateTool(tool.copy(isStar = false))
}
}
}
sealed interface StarScreenUiState {
data object Loading : StarScreenUiState
data object Nothing : StarScreenUiState
data class Success(val tools: List<ToolEntity>) : StarScreenUiState
}
private const val SEARCH_VALUE = "searchValue"

View File

@@ -86,19 +86,21 @@ internal fun ToolsRoute(
onNavigateToToolStore = onNavigateToToolStore, onNavigateToToolStore = onNavigateToToolStore,
toolsScreenUiState = toolsScreenUiStateState, toolsScreenUiState = toolsScreenUiStateState,
onUninstall = viewModel::uninstall, onUninstall = viewModel::uninstall,
onUndo = viewModel::undo onUndo = viewModel::undo,
onChangeStar = viewModel::changeStar
) )
} }
@Composable @Composable
internal fun ToolsScreen( internal fun ToolsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
toolsScreenUiState: ToolsScreenUiState,
onShowSnackbar: suspend (message: String, action: String?) -> Boolean, onShowSnackbar: suspend (message: String, action: String?) -> Boolean,
onNavigateToToolView: (username: String, toolId: String, preview: Boolean) -> Unit, onNavigateToToolView: (username: String, toolId: String, preview: Boolean) -> Unit,
onNavigateToToolStore: () -> Unit, onNavigateToToolStore: () -> Unit,
toolsScreenUiState: ToolsScreenUiState,
onUninstall: (ToolEntity) -> Unit, onUninstall: (ToolEntity) -> Unit,
onUndo: (ToolEntity) -> Unit onUndo: (ToolEntity) -> Unit,
onChangeStar: (ToolEntity, Boolean) -> Unit
) { ) {
val localContext = LocalContext.current val localContext = LocalContext.current
@@ -164,16 +166,18 @@ internal fun ToolsScreen(
state = state state = state
) { ) {
toolsPanel(toolItems = toolsScreenUiState.tools, toolsPanel(
toolItems = toolsScreenUiState.tools,
onClick = onNavigateToToolView, onClick = onNavigateToToolView,
onLongClick = { onLongClick = {
selectedTool = it selectedTool = it
isShowMenu = true isShowMenu = true
}) }
)
item(span = StaggeredGridItemSpan.FullLine) { item(span = StaggeredGridItemSpan.FullLine) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
} }
} }
} }
@@ -207,6 +211,10 @@ internal fun ToolsScreen(
onUndo(selectedTool!!) onUndo(selectedTool!!)
} }
} }
},
onChangeStar = {
isShowMenu = false
onChangeStar(selectedTool!!, it)
} }
) )
} }
@@ -219,11 +227,16 @@ private fun LazyStaggeredGridScope.toolsPanel(
) { ) {
items( items(
items = toolItems, items = toolItems,
key = { it.id }, key = { it.id }
) { ) {
ToolCard(tool = it, ToolCard(
tool = it,
actionIcon = if (it.isStar) OxygenIcons.Star else null,
actionIconContentDescription = stringResource(R.string.core_star),
onClick = { onClick(it.authorUsername, it.toolId, false) }, onClick = { onClick(it.authorUsername, it.toolId, false) },
onLongClick = { onLongClick(it) }) onLongClick = { onLongClick(it) },
onAction = { onClick(it.authorUsername, it.toolId, false) }
)
} }
} }
@@ -233,7 +246,8 @@ private fun ToolMenu(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onDismiss: () -> Unit, onDismiss: () -> Unit,
selectedTool: ToolEntity, selectedTool: ToolEntity,
onUninstall: () -> Unit onUninstall: () -> Unit,
onChangeStar: (Boolean) -> Unit
) { ) {
ModalBottomSheet(onDismissRequest = onDismiss, dragHandle = {}) { ModalBottomSheet(onDismissRequest = onDismiss, dragHandle = {}) {
Column( Column(
@@ -248,6 +262,11 @@ private fun ToolMenu(
text = stringResource(R.string.core_uninstall), text = stringResource(R.string.core_uninstall),
onClick = onUninstall onClick = onUninstall
) )
DialogClickerRow(
icon = if (selectedTool.isStar) OxygenIcons.StarBorder else OxygenIcons.Star,
text = stringResource(if (selectedTool.isStar) R.string.core_unstar else R.string.core_star),
onClick = { onChangeStar(!selectedTool.isStar) }
)
} }
} }
} }

View File

@@ -12,14 +12,12 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity 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 top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@HiltViewModel @HiltViewModel
class ToolsScreenViewModel @Inject constructor( class ToolsScreenViewModel @Inject constructor(
private val storeRepository: StoreRepository,
private val toolRepository: ToolRepository, private val toolRepository: ToolRepository,
private val savedStateHandle: SavedStateHandle private val savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
@@ -57,6 +55,12 @@ class ToolsScreenViewModel @Inject constructor(
toolRepository.saveTool(tool) toolRepository.saveTool(tool)
} }
} }
fun changeStar(tool: ToolEntity, star: Boolean) {
viewModelScope.launch {
toolRepository.updateTool(tool.copy(isStar = star))
}
}
} }
sealed interface ToolsScreenUiState { sealed interface ToolsScreenUiState {

View File

@@ -18,6 +18,8 @@
<string name="core_upgrading">更新中……</string> <string name="core_upgrading">更新中……</string>
<string name="core_uninstall">卸载</string> <string name="core_uninstall">卸载</string>
<string name="core_uninstall_success">卸载成功</string> <string name="core_uninstall_success">卸载成功</string>
<string name="core_star">收藏</string>
<string name="core_unstar">取消收藏</string>
<string name="core_cancel">取消</string> <string name="core_cancel">取消</string>
<string name="core_undo">撤消</string> <string name="core_undo">撤消</string>
@@ -44,6 +46,7 @@
<string name="feature_tool_view_preview_suffix">%1$s (预览)</string> <string name="feature_tool_view_preview_suffix">%1$s (预览)</string>
<string name="feature_star_title">收藏</string> <string name="feature_star_title">收藏</string>
<string name="feature_star_no_tools_starred">暂无工具已收藏</string>
<string name="feature_settings_title">设置</string> <string name="feature_settings_title">设置</string>
<string name="feature_settings_language">语言</string> <string name="feature_settings_language">语言</string>

View File

@@ -17,6 +17,8 @@
<string name="core_upgrading">Upgrading…</string> <string name="core_upgrading">Upgrading…</string>
<string name="core_uninstall">Uninstall</string> <string name="core_uninstall">Uninstall</string>
<string name="core_uninstall_success">Uninstalled successfully</string> <string name="core_uninstall_success">Uninstalled successfully</string>
<string name="core_star">Star</string>
<string name="core_unstar">Unstar</string>
<string name="core_cancel">Cancel</string> <string name="core_cancel">Cancel</string>
<string name="core_undo">Undo</string> <string name="core_undo">Undo</string>
@@ -43,6 +45,7 @@
<string name="feature_tool_view_preview_suffix">%1$s (Preview)</string> <string name="feature_tool_view_preview_suffix">%1$s (Preview)</string>
<string name="feature_star_title">Star</string> <string name="feature_star_title">Star</string>
<string name="feature_star_no_tools_starred">No tools starred yet</string>
<string name="feature_settings_title">Settings</string> <string name="feature_settings_title">Settings</string>
<string name="feature_settings_language">Language</string> <string name="feature_settings_language">Language</string>