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 package top.fatweb.oxygen.toolbox.icon
import androidx.compose.material.icons.Icons 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.AccessTime
import androidx.compose.material.icons.filled.Build 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.Code
import androidx.compose.material.icons.filled.Inbox import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.MoreVert 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 import androidx.compose.material.icons.rounded.Star
object OxygenIcons { object OxygenIcons {
val ArrowBack = Icons.AutoMirrored.Rounded.ArrowBack
val ArrowDown = Icons.Rounded.KeyboardArrowDown val ArrowDown = Icons.Rounded.KeyboardArrowDown
val Back = Icons.Rounded.ArrowBackIosNew val Back = Icons.Rounded.ArrowBackIosNew
val Box = Icons.Default.Inbox val Box = Icons.Default.Inbox
val Close = Icons.Default.Close
val Code = Icons.Default.Code val Code = Icons.Default.Code
val Home = Icons.Rounded.Home val Home = Icons.Rounded.Home
val HomeBorder = Icons.Outlined.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 import top.fatweb.oxygen.toolbox.model.lib.Dependencies
interface DepRepository { 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 package top.fatweb.oxygen.toolbox.repository.lib
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow 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.data.lib.DepDataSource
import top.fatweb.oxygen.toolbox.model.lib.Dependencies import top.fatweb.oxygen.toolbox.model.lib.Dependencies
import javax.inject.Inject import javax.inject.Inject
class LocalDepRepository @Inject constructor( class LocalDepRepository @Inject constructor(
depDataSource: DepDataSource private val depDataSource: DepDataSource
) : DepRepository { ) : DepRepository {
override val dependencies: Flow<Dependencies> = @OptIn(ExperimentalCoroutinesApi::class)
depDataSource.dependencies 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 onClickLicense: (key: String) -> Unit
) { ) {
when (librariesScreenUiState) { when (librariesScreenUiState) {
LibrariesScreenUiState.Loading -> Unit LibrariesScreenUiState.Loading, LibrariesScreenUiState.Nothing, LibrariesScreenUiState.NotFound -> Unit
is LibrariesScreenUiState.Success -> { is LibrariesScreenUiState.Success -> {
items( items(

View File

@@ -73,7 +73,8 @@ internal fun LibrariesRoute(
LibrariesScreen( LibrariesScreen(
modifier = modifier.safeDrawingPadding(), modifier = modifier.safeDrawingPadding(),
librariesScreenUiState = librariesScreenUiState, librariesScreenUiState = librariesScreenUiState,
onBackClick = onBackClick onBackClick = onBackClick,
onSearch = { viewModel.onSearchValueChange(it) }
) )
} }
@@ -82,7 +83,8 @@ internal fun LibrariesRoute(
internal fun LibrariesScreen( internal fun LibrariesScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
librariesScreenUiState: LibrariesScreenUiState, librariesScreenUiState: LibrariesScreenUiState,
onBackClick: () -> Unit onBackClick: () -> Unit,
onSearch: (String) -> Unit
) { ) {
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val context = LocalContext.current val context = LocalContext.current
@@ -101,7 +103,10 @@ internal fun LibrariesScreen(
var dialogContent by remember { mutableStateOf("") } var dialogContent by remember { mutableStateOf("") }
var dialogUrl 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( Scaffold(
modifier = Modifier modifier = Modifier
@@ -116,11 +121,26 @@ internal fun LibrariesScreen(
navigationIconContentDescription = stringResource(R.string.core_back), navigationIconContentDescription = stringResource(R.string.core_back),
actionIcon = OxygenIcons.Search, actionIcon = OxygenIcons.Search,
actionIconContentDescription = stringResource(R.string.core_search), actionIconContentDescription = stringResource(R.string.core_search),
activeSearch = activeSearch,
query = searchValue,
colors = TopAppBarDefaults.centerAlignedTopAppBarColors( colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
scrolledContainerColor = 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 -> ) { padding ->
@@ -140,6 +160,14 @@ internal fun LibrariesScreen(
Text(text = stringResource(R.string.feature_settings_loading)) Text(text = stringResource(R.string.feature_settings_loading))
} }
LibrariesScreenUiState.Nothing -> {
Text(text = "Nothing")
}
LibrariesScreenUiState.NotFound -> {
Text(text = "Not Found")
}
is LibrariesScreenUiState.Success -> { is LibrariesScreenUiState.Success -> {
val handleOnClickLicense = { key: String -> val handleOnClickLicense = { key: String ->
val license = librariesScreenUiState.dependencies.licenses[key] val license = librariesScreenUiState.dependencies.licenses[key]
@@ -225,7 +253,7 @@ internal fun LibrariesScreen(
fun howManyItems(librariesScreenUiState: LibrariesScreenUiState) = fun howManyItems(librariesScreenUiState: LibrariesScreenUiState) =
when (librariesScreenUiState) { when (librariesScreenUiState) {
LibrariesScreenUiState.Loading -> 0 LibrariesScreenUiState.Loading, LibrariesScreenUiState.Nothing, LibrariesScreenUiState.NotFound -> 0
is LibrariesScreenUiState.Success -> librariesScreenUiState.dependencies.libraries.size is LibrariesScreenUiState.Success -> librariesScreenUiState.dependencies.libraries.size
} }
@@ -234,6 +262,9 @@ fun howManyItems(librariesScreenUiState: LibrariesScreenUiState) =
@Composable @Composable
private fun LibrariesScreenLoadingPreview() { private fun LibrariesScreenLoadingPreview() {
OxygenTheme { 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 package top.fatweb.oxygen.toolbox.ui.about
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import top.fatweb.oxygen.toolbox.model.lib.Dependencies import top.fatweb.oxygen.toolbox.model.lib.Dependencies
@@ -14,22 +18,49 @@ import kotlin.time.Duration.Companion.seconds
@HiltViewModel @HiltViewModel
class LibrariesScreenViewModel @Inject constructor( class LibrariesScreenViewModel @Inject constructor(
depRepository: DepRepository private val depRepository: DepRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
private val searchValue = savedStateHandle.getStateFlow(SEARCH_VALUE, "")
@OptIn(ExperimentalCoroutinesApi::class)
val librariesScreenUiState: StateFlow<LibrariesScreenUiState> = val librariesScreenUiState: StateFlow<LibrariesScreenUiState> =
depRepository.dependencies depRepository.getSearchNameCount()
.map { .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) LibrariesScreenUiState.Success(it)
} }
}
}
}
}
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
initialValue = LibrariesScreenUiState.Loading, initialValue = LibrariesScreenUiState.Loading,
started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds) started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds)
) )
fun onSearchValueChange(value: String) {
savedStateHandle[SEARCH_VALUE] = value
}
} }
sealed interface LibrariesScreenUiState { sealed interface LibrariesScreenUiState {
data object Loading: LibrariesScreenUiState data object Loading: LibrariesScreenUiState
data object Nothing: LibrariesScreenUiState
data object NotFound: LibrariesScreenUiState
data class Success(val dependencies: Dependencies) : 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.annotation.StringRes
import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.animateIntAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.WindowInsets 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.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.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.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.icon.OxygenIcons import top.fatweb.oxygen.toolbox.icon.OxygenIcons
import top.fatweb.oxygen.toolbox.ui.theme.OxygenPreviews import top.fatweb.oxygen.toolbox.ui.theme.OxygenPreviews
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
@@ -34,18 +51,77 @@ fun OxygenTopAppBar(
navigationIconContentDescription: String? = null, navigationIconContentDescription: String? = null,
actionIcon: ImageVector? = null, actionIcon: ImageVector? = null,
actionIconContentDescription: String? = null, actionIconContentDescription: String? = null,
activeSearch: Boolean = false,
query: String = "",
colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(), colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(),
onNavigationClick: () -> Unit = {}, onNavigationClick: () -> Unit = {},
onActionClick: () -> Unit = {} onActionClick: () -> Unit = {},
onQueryChange: (String) -> Unit = {},
onSearch: (String) -> Unit = {},
onCancelSearch: () -> Unit = {}
) { ) {
val topInset by animateIntAsState( val topInset by animateIntAsState(
if (scrollBehavior != null && -scrollBehavior.state.heightOffset >= with(LocalDensity.current) { 64.0.dp.toPx() }) 0 if (scrollBehavior != null && -scrollBehavior.state.heightOffset >= with(LocalDensity.current) { 64.0.dp.toPx() }) 0
else TopAppBarDefaults.windowInsets.getTop(LocalDensity.current), label = "" 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( CenterAlignedTopAppBar(
modifier = modifier, modifier = modifier,
scrollBehavior = scrollBehavior, 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 = {
navigationIcon?.let { navigationIcon?.let {
IconButton(onClick = onNavigationClick) { IconButton(onClick = onNavigationClick) {
@@ -58,7 +134,13 @@ fun OxygenTopAppBar(
} }
}, },
actions = { actions = {
actionIcon?.let { if (activeSearch) IconButton(onClick = onCancelSearch) {
Icon(
imageVector = OxygenIcons.Close,
contentDescription = stringResource(R.string.core_close)
)
}
else actionIcon?.let {
IconButton(onClick = onActionClick) { IconButton(onClick = onActionClick) {
Icon( Icon(
imageVector = actionIcon, imageVector = actionIcon,