diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/icon/OxygenIcons.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/icon/OxygenIcons.kt index 6402dca..a317bd8 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/icon/OxygenIcons.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/icon/OxygenIcons.kt @@ -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 diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/lib/DepRepository.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/lib/DepRepository.kt index b10c1b9..4a91613 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/lib/DepRepository.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/lib/DepRepository.kt @@ -4,5 +4,7 @@ import kotlinx.coroutines.flow.Flow import top.fatweb.oxygen.toolbox.model.lib.Dependencies interface DepRepository { - val dependencies: Flow + fun searchName(name: String): Flow + + fun getSearchNameCount(): Flow } \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/lib/LocalDepRepository.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/lib/LocalDepRepository.kt index bb5bbff..ad8b795 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/lib/LocalDepRepository.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/lib/LocalDepRepository.kt @@ -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 = - depDataSource.dependencies + @OptIn(ExperimentalCoroutinesApi::class) + override fun searchName(name: String): Flow = + 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 = + depDataSource.dependencies.flatMapLatest { flowOf(it.libraries.size) } } \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesPanel.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesPanel.kt index 95f1027..f2a8668 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesPanel.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesPanel.kt @@ -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( diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesScreen.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesScreen.kt index 12dfe8a..5b8f64c 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesScreen.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesScreen.kt @@ -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 = {}) } } diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesScreenViewModel.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesScreenViewModel.kt index 551d233..351d5df 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesScreenViewModel.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesScreenViewModel.kt @@ -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 = - 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" diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/OxygenTopAppBar.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/OxygenTopAppBar.kt index 10cbbd2..45ce610 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/OxygenTopAppBar.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/OxygenTopAppBar.kt @@ -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,