Refactor(LibrariesScreen): Add search
Add search and optimize top bar
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
LibrariesScreenUiState.Success(it)
|
||||
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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user