Refactor(LibrariesScreen): Add search

Add search and optimize top bar
This commit is contained in:
2024-04-25 14:59:47 +08:00
parent 61d229b100
commit 2e0efd1cb9
7 changed files with 180 additions and 20 deletions

View File

@@ -1,9 +1,9 @@
package top.fatweb.oxygen.toolbox.icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.MoreVert
@@ -18,10 +18,10 @@ import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Star
object OxygenIcons {
val ArrowBack = Icons.AutoMirrored.Rounded.ArrowBack
val ArrowDown = Icons.Rounded.KeyboardArrowDown
val Back = Icons.Rounded.ArrowBackIosNew
val Box = Icons.Default.Inbox
val Close = Icons.Default.Close
val Code = Icons.Default.Code
val Home = Icons.Rounded.Home
val HomeBorder = Icons.Outlined.Home

View File

@@ -4,5 +4,7 @@ import kotlinx.coroutines.flow.Flow
import top.fatweb.oxygen.toolbox.model.lib.Dependencies
interface DepRepository {
val dependencies: Flow<Dependencies>
fun searchName(name: String): Flow<Dependencies>
fun getSearchNameCount(): Flow<Int>
}

View File

@@ -1,13 +1,27 @@
package top.fatweb.oxygen.toolbox.repository.lib
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import top.fatweb.oxygen.toolbox.data.lib.DepDataSource
import top.fatweb.oxygen.toolbox.model.lib.Dependencies
import javax.inject.Inject
class LocalDepRepository @Inject constructor(
depDataSource: DepDataSource
private val depDataSource: DepDataSource
) : DepRepository {
override val dependencies: Flow<Dependencies> =
depDataSource.dependencies
@OptIn(ExperimentalCoroutinesApi::class)
override fun searchName(name: String): Flow<Dependencies> =
depDataSource.dependencies.flatMapLatest { dependencies ->
flowOf(dependencies.copy(
libraries = dependencies.libraries.filter {
it.name?.lowercase()?.contains(Regex("^.*${name.lowercase()}.*$")) ?: false
}
))
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun getSearchNameCount(): Flow<Int> =
depDataSource.dependencies.flatMapLatest { flowOf(it.libraries.size) }
}

View File

@@ -9,7 +9,7 @@ fun LazyStaggeredGridScope.librariesPanel(
onClickLicense: (key: String) -> Unit
) {
when (librariesScreenUiState) {
LibrariesScreenUiState.Loading -> Unit
LibrariesScreenUiState.Loading, LibrariesScreenUiState.Nothing, LibrariesScreenUiState.NotFound -> Unit
is LibrariesScreenUiState.Success -> {
items(

View File

@@ -73,7 +73,8 @@ internal fun LibrariesRoute(
LibrariesScreen(
modifier = modifier.safeDrawingPadding(),
librariesScreenUiState = librariesScreenUiState,
onBackClick = onBackClick
onBackClick = onBackClick,
onSearch = { viewModel.onSearchValueChange(it) }
)
}
@@ -82,7 +83,8 @@ internal fun LibrariesRoute(
internal fun LibrariesScreen(
modifier: Modifier = Modifier,
librariesScreenUiState: LibrariesScreenUiState,
onBackClick: () -> Unit
onBackClick: () -> Unit,
onSearch: (String) -> Unit
) {
val configuration = LocalConfiguration.current
val context = LocalContext.current
@@ -101,7 +103,10 @@ internal fun LibrariesScreen(
var dialogContent by remember { mutableStateOf("") }
var dialogUrl by remember { mutableStateOf("") }
val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(canScroll = { state.canScrollForward })
var activeSearch by remember { mutableStateOf(false) }
var searchValue by remember { mutableStateOf("") }
Scaffold(
modifier = Modifier
@@ -116,11 +121,26 @@ internal fun LibrariesScreen(
navigationIconContentDescription = stringResource(R.string.core_back),
actionIcon = OxygenIcons.Search,
actionIconContentDescription = stringResource(R.string.core_search),
activeSearch = activeSearch,
query = searchValue,
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
),
onNavigationClick = onBackClick
onNavigationClick = onBackClick,
onActionClick = {
activeSearch = true
},
onQueryChange = {
searchValue = it
onSearch(it)
},
onSearch = onSearch,
onCancelSearch = {
searchValue = ""
activeSearch = false
onSearch("")
}
)
}
) { padding ->
@@ -140,6 +160,14 @@ internal fun LibrariesScreen(
Text(text = stringResource(R.string.feature_settings_loading))
}
LibrariesScreenUiState.Nothing -> {
Text(text = "Nothing")
}
LibrariesScreenUiState.NotFound -> {
Text(text = "Not Found")
}
is LibrariesScreenUiState.Success -> {
val handleOnClickLicense = { key: String ->
val license = librariesScreenUiState.dependencies.licenses[key]
@@ -225,7 +253,7 @@ internal fun LibrariesScreen(
fun howManyItems(librariesScreenUiState: LibrariesScreenUiState) =
when (librariesScreenUiState) {
LibrariesScreenUiState.Loading -> 0
LibrariesScreenUiState.Loading, LibrariesScreenUiState.Nothing, LibrariesScreenUiState.NotFound -> 0
is LibrariesScreenUiState.Success -> librariesScreenUiState.dependencies.libraries.size
}
@@ -234,6 +262,9 @@ fun howManyItems(librariesScreenUiState: LibrariesScreenUiState) =
@Composable
private fun LibrariesScreenLoadingPreview() {
OxygenTheme {
LibrariesScreen(librariesScreenUiState = LibrariesScreenUiState.Loading, onBackClick = {})
LibrariesScreen(
librariesScreenUiState = LibrariesScreenUiState.Loading,
onBackClick = {},
onSearch = {})
}
}

View File

@@ -1,10 +1,14 @@
package top.fatweb.oxygen.toolbox.ui.about
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.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import top.fatweb.oxygen.toolbox.model.lib.Dependencies
@@ -14,22 +18,49 @@ import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class LibrariesScreenViewModel @Inject constructor(
depRepository: DepRepository
private val depRepository: DepRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val searchValue = savedStateHandle.getStateFlow(SEARCH_VALUE, "")
@OptIn(ExperimentalCoroutinesApi::class)
val librariesScreenUiState: StateFlow<LibrariesScreenUiState> =
depRepository.dependencies
.map {
depRepository.getSearchNameCount()
.flatMapLatest {totalCount ->
if (totalCount < SEARCH_MIN_COUNT) {
flowOf(LibrariesScreenUiState.Nothing)
} else {
searchValue.flatMapLatest {value ->
depRepository.searchName(value).map {
if (it.libraries.isEmpty()) {
LibrariesScreenUiState.NotFound
} else {
LibrariesScreenUiState.Success(it)
}
}
}
}
}
.stateIn(
scope = viewModelScope,
initialValue = LibrariesScreenUiState.Loading,
started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds)
)
fun onSearchValueChange(value: String) {
savedStateHandle[SEARCH_VALUE] = value
}
}
sealed interface LibrariesScreenUiState {
data object Loading: LibrariesScreenUiState
data object Nothing: LibrariesScreenUiState
data object NotFound: LibrariesScreenUiState
data class Success(val dependencies: Dependencies) : LibrariesScreenUiState
}
private const val SEARCH_MIN_COUNT = 1
private const val SEARCH_VALUE = "searchValue"

View File

@@ -2,23 +2,40 @@ package top.fatweb.oxygen.toolbox.ui.component
import androidx.annotation.StringRes
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
import top.fatweb.oxygen.toolbox.ui.theme.OxygenPreviews
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
@@ -34,18 +51,77 @@ fun OxygenTopAppBar(
navigationIconContentDescription: String? = null,
actionIcon: ImageVector? = null,
actionIconContentDescription: String? = null,
activeSearch: Boolean = false,
query: String = "",
colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(),
onNavigationClick: () -> Unit = {},
onActionClick: () -> Unit = {}
onActionClick: () -> Unit = {},
onQueryChange: (String) -> Unit = {},
onSearch: (String) -> Unit = {},
onCancelSearch: () -> Unit = {}
) {
val topInset by animateIntAsState(
if (scrollBehavior != null && -scrollBehavior.state.heightOffset >= with(LocalDensity.current) { 64.0.dp.toPx() }) 0
else TopAppBarDefaults.windowInsets.getTop(LocalDensity.current), label = ""
)
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
val onSearchExplicitlyTriggered = {
keyboardController?.hide()
onSearch(query)
}
LaunchedEffect(activeSearch) {
if (activeSearch) {
focusRequester.requestFocus()
}
}
CenterAlignedTopAppBar(
modifier = modifier,
scrollBehavior = scrollBehavior,
title = { Text(stringResource(titleRes)) },
title = {
if (activeSearch) TextField(
modifier = Modifier
.fillMaxWidth()
.background(Color.Transparent)
.focusRequester(focusRequester)
.onKeyEvent {
if (it.key == Key.Enter) {
onSearchExplicitlyTriggered()
true
} else {
false
}
},
value = query,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = {
onSearchExplicitlyTriggered()
}
),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent
),
leadingIcon = {
Icon(
imageVector = OxygenIcons.Search,
contentDescription = stringResource(R.string.core_search)
)
},
maxLines = 1,
singleLine = true,
onValueChange = {
if ("\n" !in it) onQueryChange(it)
}
)
else Text(stringResource(titleRes))
},
navigationIcon = {
navigationIcon?.let {
IconButton(onClick = onNavigationClick) {
@@ -58,7 +134,13 @@ fun OxygenTopAppBar(
}
},
actions = {
actionIcon?.let {
if (activeSearch) IconButton(onClick = onCancelSearch) {
Icon(
imageVector = OxygenIcons.Close,
contentDescription = stringResource(R.string.core_close)
)
}
else actionIcon?.let {
IconButton(onClick = onActionClick) {
Icon(
imageVector = actionIcon,