From da4c58b6311251f7ca716aa2d07df282242bd63f Mon Sep 17 00:00:00 2001 From: FatttSnake Date: Tue, 20 Aug 2024 15:31:24 +0800 Subject: [PATCH] Feat(ToolStarScreen): Support star tool --- .../oxygen/toolbox/data/tool/dao/ToolDao.kt | 9 + .../toolbox/navigation/OxygenNavHost.kt | 4 +- .../toolbox/navigation/StarNavigation.kt | 9 +- .../toolbox/repository/tool/ToolRepository.kt | 2 + .../tool/impl/OfflineToolRepository.kt | 3 + .../oxygen/toolbox/ui/star/StarScreen.kt | 20 -- .../oxygen/toolbox/ui/star/ToolStarScreen.kt | 237 ++++++++++++++++++ .../ui/star/ToolStarScreenViewModel.kt | 59 +++++ .../oxygen/toolbox/ui/tools/ToolsScreen.kt | 39 ++- .../toolbox/ui/tools/ToolsScreenViewModel.kt | 8 +- app/src/main/res/values-zh/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 12 files changed, 361 insertions(+), 35 deletions(-) delete mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/star/StarScreen.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/star/ToolStarScreen.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/star/ToolStarScreenViewModel.kt diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/data/tool/dao/ToolDao.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/data/tool/dao/ToolDao.kt index 34bf0ff..3826a11 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/data/tool/dao/ToolDao.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/data/tool/dao/ToolDao.kt @@ -31,6 +31,15 @@ interface ToolDao { "ORDER BY updateTime DESC") fun selectAllTools(searchValue: String): Flow> + @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> + @Query("SELECT * FROM tools " + "WHERE authorUsername = :username " + "and toolId = :toolId LIMIT 1") diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/OxygenNavHost.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/OxygenNavHost.kt index 720ebdd..957e636 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/OxygenNavHost.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/OxygenNavHost.kt @@ -45,7 +45,9 @@ fun OxygenNavHost( onBackClick = navController::popBackStack ) starScreen( - isVertical = isVertical + isVertical = isVertical, + searchValue = searchValue, + onNavigateToToolView = navController::navigateToToolView ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/StarNavigation.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/StarNavigation.kt index eea2421..7b2c4cb 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/StarNavigation.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/StarNavigation.kt @@ -15,7 +15,9 @@ const val STAR_ROUTE = "star_route" fun NavController.navigateToStar(navOptions: NavOptions) = navigate(STAR_ROUTE, navOptions) fun NavGraphBuilder.starScreen( - isVertical: Boolean + isVertical: Boolean, + searchValue: String, + onNavigateToToolView: (username: String, toolId: String, preview: Boolean) -> Unit ) { composable( route = STAR_ROUTE, @@ -38,6 +40,9 @@ fun NavGraphBuilder.starScreen( } } ) { - StarRoute() + StarRoute( + searchValue = searchValue, + onNavigateToToolView = onNavigateToToolView + ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/ToolRepository.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/ToolRepository.kt index c5543da..d30f138 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/ToolRepository.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/ToolRepository.kt @@ -8,6 +8,8 @@ interface ToolRepository { fun getAllToolsStream(searchValue: String): Flow> + fun getStarToolsStream(searchValue: String): Flow> + fun getToolById(id: Long): Flow fun getToolByUsernameAndToolId(username: String, toolId: String): Flow diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/impl/OfflineToolRepository.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/impl/OfflineToolRepository.kt index abd2858..47ebe73 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/impl/OfflineToolRepository.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/impl/OfflineToolRepository.kt @@ -17,6 +17,9 @@ class OfflineToolRepository @Inject constructor( override fun getAllToolsStream(searchValue: String): Flow> = toolDao.selectAllTools(searchValue) + override fun getStarToolsStream(searchValue: String): Flow> = + toolDao.selectStarTools(searchValue) + override fun getToolById(id: Long): Flow = toolDao.selectToolById(id) diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/star/StarScreen.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/star/StarScreen.kt deleted file mode 100644 index e422ab6..0000000 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/star/StarScreen.kt +++ /dev/null @@ -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 -) { - -} \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/star/ToolStarScreen.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/star/ToolStarScreen.kt new file mode 100644 index 0000000..bf6e35c --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/star/ToolStarScreen.kt @@ -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(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, + 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 + } diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/star/ToolStarScreenViewModel.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/star/ToolStarScreenViewModel.kt new file mode 100644 index 0000000..8c256e6 --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/star/ToolStarScreenViewModel.kt @@ -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 = + 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) : StarScreenUiState +} + +private const val SEARCH_VALUE = "searchValue" \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tools/ToolsScreen.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tools/ToolsScreen.kt index f207e70..b1f29c3 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tools/ToolsScreen.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tools/ToolsScreen.kt @@ -86,19 +86,21 @@ internal fun ToolsRoute( onNavigateToToolStore = onNavigateToToolStore, toolsScreenUiState = toolsScreenUiStateState, onUninstall = viewModel::uninstall, - onUndo = viewModel::undo + onUndo = viewModel::undo, + onChangeStar = viewModel::changeStar ) } @Composable internal fun ToolsScreen( modifier: Modifier = Modifier, + toolsScreenUiState: ToolsScreenUiState, onShowSnackbar: suspend (message: String, action: String?) -> Boolean, onNavigateToToolView: (username: String, toolId: String, preview: Boolean) -> Unit, onNavigateToToolStore: () -> Unit, - toolsScreenUiState: ToolsScreenUiState, onUninstall: (ToolEntity) -> Unit, - onUndo: (ToolEntity) -> Unit + onUndo: (ToolEntity) -> Unit, + onChangeStar: (ToolEntity, Boolean) -> Unit ) { val localContext = LocalContext.current @@ -164,16 +166,18 @@ internal fun ToolsScreen( state = state ) { - toolsPanel(toolItems = toolsScreenUiState.tools, + toolsPanel( + toolItems = toolsScreenUiState.tools, onClick = onNavigateToToolView, onLongClick = { selectedTool = it isShowMenu = true - }) + } + ) item(span = StaggeredGridItemSpan.FullLine) { 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!!) } } + }, + onChangeStar = { + isShowMenu = false + onChangeStar(selectedTool!!, it) } ) } @@ -219,11 +227,16 @@ private fun LazyStaggeredGridScope.toolsPanel( ) { items( 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) }, - onLongClick = { onLongClick(it) }) + onLongClick = { onLongClick(it) }, + onAction = { onClick(it.authorUsername, it.toolId, false) } + ) } } @@ -233,7 +246,8 @@ private fun ToolMenu( modifier: Modifier = Modifier, onDismiss: () -> Unit, selectedTool: ToolEntity, - onUninstall: () -> Unit + onUninstall: () -> Unit, + onChangeStar: (Boolean) -> Unit ) { ModalBottomSheet(onDismissRequest = onDismiss, dragHandle = {}) { Column( @@ -248,6 +262,11 @@ private fun ToolMenu( text = stringResource(R.string.core_uninstall), 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) } + ) } } } diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tools/ToolsScreenViewModel.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tools/ToolsScreenViewModel.kt index 6daf289..8b5022d 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tools/ToolsScreenViewModel.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tools/ToolsScreenViewModel.kt @@ -12,14 +12,12 @@ 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.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 storeRepository: StoreRepository, private val toolRepository: ToolRepository, private val savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -57,6 +55,12 @@ class ToolsScreenViewModel @Inject constructor( toolRepository.saveTool(tool) } } + + fun changeStar(tool: ToolEntity, star: Boolean) { + viewModelScope.launch { + toolRepository.updateTool(tool.copy(isStar = star)) + } + } } sealed interface ToolsScreenUiState { diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index c3cba93..4e90d23 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -18,6 +18,8 @@ 更新中…… 卸载 卸载成功 + 收藏 + 取消收藏 取消 撤消 @@ -44,6 +46,7 @@ %1$s (预览) 收藏 + 暂无工具已收藏 设置 语言 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e11509..a6bc4b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,8 @@ Upgrading… Uninstall Uninstalled successfully + Star + Unstar Cancel Undo @@ -43,6 +45,7 @@ %1$s (Preview) Star + No tools starred yet Settings Language