feat(settings): place the search bar at the top or the bottom
Add a search bar position setting and render the bar above the results or below them, with window insets applied to match.
This commit is contained in:
1 parent
c4e4664542
commit
73384bc0eb
15 files changed
+279
-48
No files matched your search
@@ -26,6 +26,7 @@ import com.example.research.common.ui.AppLocaleProvider
|
||||
import com.example.research.common.ui.BackgroundTextMeasurementProvider
|
||||
import com.example.research.common.ui.theme.ReSearchTheme
|
||||
import com.example.research.core.domain.model.AppTheme
|
||||
import com.example.research.core.domain.model.SearchBarPosition
|
||||
import com.example.research.data.local.preferences.SectionExpansionStates
|
||||
import com.example.research.feature.search.SearchAction
|
||||
import com.example.research.feature.search.SearchViewModel
|
||||
@@ -68,11 +69,8 @@ class MainActivity : ComponentActivity() {
|
||||
app.dictionaryImportManager,
|
||||
app.dictionaryProgressStateHolder,
|
||||
versionName,
|
||||
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(
|
||||
isThemeExpanded = false,
|
||||
isLanguageExpanded = false,
|
||||
isDictionariesExpanded = true,
|
||||
),
|
||||
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(),
|
||||
app.initialLaunch.value?.searchBarPosition ?: SearchBarPosition.TOP,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.Application
|
||||
import com.example.research.common.progress.DictionaryProgressStateHolder
|
||||
import com.example.research.common.util.NotificationHelper
|
||||
import com.example.research.core.domain.model.AppTheme
|
||||
import com.example.research.core.domain.model.SearchBarPosition
|
||||
import com.example.research.data.local.preferences.PreferencesManager
|
||||
import com.example.research.data.local.preferences.SectionExpansionStates
|
||||
import com.example.research.data.repository.LocalDictionaryRepository
|
||||
@@ -29,6 +30,7 @@ import okhttp3.Protocol
|
||||
data class InitialLaunch(
|
||||
val theme: AppTheme,
|
||||
val language: String,
|
||||
val searchBarPosition: SearchBarPosition,
|
||||
val seedNoDictionaries: Boolean,
|
||||
val sectionExpansion: SectionExpansionStates,
|
||||
)
|
||||
@@ -145,19 +147,16 @@ class ReSearchApplication : Application() {
|
||||
}
|
||||
|
||||
private suspend fun loadInitialLaunch(): InitialLaunch {
|
||||
val defaultExpansions = SectionExpansionStates(
|
||||
isThemeExpanded = false,
|
||||
isLanguageExpanded = false,
|
||||
isDictionariesExpanded = true,
|
||||
)
|
||||
return InitialLaunch(
|
||||
theme = runCatching { AppTheme.valueOf(preferencesManager.themeMode.first()) }
|
||||
.getOrDefault(AppTheme.SYSTEM),
|
||||
language = runCatching { preferencesManager.language.first() }.getOrDefault("system"),
|
||||
searchBarPosition = runCatching { preferencesManager.searchBarPosition.first() }
|
||||
.getOrDefault(SearchBarPosition.TOP),
|
||||
seedNoDictionaries = runCatching { preferencesManager.hadNoDictionaries.first() }
|
||||
.getOrDefault(true),
|
||||
sectionExpansion = runCatching { preferencesManager.sectionExpansionStates() }
|
||||
.getOrDefault(defaultExpansions),
|
||||
.getOrDefault(SectionExpansionStates()),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.research.core.domain.model
|
||||
|
||||
enum class SearchBarPosition {
|
||||
TOP,
|
||||
BOTTOM;
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): SearchBarPosition =
|
||||
entries.firstOrNull { it.name == value } ?: TOP
|
||||
}
|
||||
}
|
||||
+26
-3
@@ -9,7 +9,9 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.example.research.core.domain.model.DictionarySource
|
||||
import com.example.research.core.domain.model.SearchBarPosition
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -18,9 +20,10 @@ import java.io.File
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
data class SectionExpansionStates(
|
||||
val isThemeExpanded: Boolean,
|
||||
val isLanguageExpanded: Boolean,
|
||||
val isDictionariesExpanded: Boolean,
|
||||
val isThemeExpanded: Boolean = false,
|
||||
val isLanguageExpanded: Boolean = false,
|
||||
val isSearchBarExpanded: Boolean = false,
|
||||
val isDictionariesExpanded: Boolean = true,
|
||||
)
|
||||
|
||||
class PreferencesManager(private val context: Context) {
|
||||
@@ -28,8 +31,10 @@ class PreferencesManager(private val context: Context) {
|
||||
val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val DICTIONARY_SOURCES = stringPreferencesKey("dictionary_sources")
|
||||
val LANGUAGE = stringPreferencesKey("language")
|
||||
val SEARCH_BAR_POSITION = stringPreferencesKey("search_bar_position")
|
||||
val IS_THEME_EXPANDED = booleanPreferencesKey("is_theme_expanded")
|
||||
val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded")
|
||||
val IS_SEARCH_BAR_EXPANDED = booleanPreferencesKey("is_search_bar_expanded")
|
||||
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
|
||||
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
|
||||
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
|
||||
@@ -57,6 +62,17 @@ class PreferencesManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val searchBarPosition: Flow<SearchBarPosition> = context.dataStore.data
|
||||
.map { preferences ->
|
||||
SearchBarPosition.fromStorage(preferences[PreferencesKeys.SEARCH_BAR_POSITION])
|
||||
}
|
||||
|
||||
suspend fun saveSearchBarPosition(position: SearchBarPosition) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.SEARCH_BAR_POSITION] = position.name
|
||||
}
|
||||
}
|
||||
|
||||
val dictionarySources: Flow<List<DictionarySource>> = context.dataStore.data
|
||||
.map { preferences ->
|
||||
val sourcesJson = preferences[PreferencesKeys.DICTIONARY_SOURCES]
|
||||
@@ -101,6 +117,7 @@ class PreferencesManager(private val context: Context) {
|
||||
SectionExpansionStates(
|
||||
isThemeExpanded = preferences[PreferencesKeys.IS_THEME_EXPANDED] ?: false,
|
||||
isLanguageExpanded = preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false,
|
||||
isSearchBarExpanded = preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] ?: false,
|
||||
isDictionariesExpanded = preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true,
|
||||
)
|
||||
}
|
||||
@@ -143,6 +160,11 @@ class PreferencesManager(private val context: Context) {
|
||||
preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false
|
||||
}
|
||||
|
||||
val isSearchBarExpanded: Flow<Boolean> = context.dataStore.data
|
||||
.map { preferences ->
|
||||
preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] ?: false
|
||||
}
|
||||
|
||||
val isDictionariesExpanded: Flow<Boolean> = context.dataStore.data
|
||||
.map { preferences ->
|
||||
preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true
|
||||
@@ -153,6 +175,7 @@ class PreferencesManager(private val context: Context) {
|
||||
when (key) {
|
||||
"theme" -> preferences[PreferencesKeys.IS_THEME_EXPANDED] = isExpanded
|
||||
"language" -> preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] = isExpanded
|
||||
"search_bar" -> preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] = isExpanded
|
||||
"dictionaries" -> preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] = isExpanded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ fun AdaptiveMainScreen(
|
||||
searchState: TextFieldState,
|
||||
canSearch: Boolean,
|
||||
isLandscape: Boolean,
|
||||
isSearchBarAtBottom: Boolean,
|
||||
results: LazyPagingItems<IndexEntry>,
|
||||
errorMessage: String?,
|
||||
showDictionaryName: Boolean,
|
||||
@@ -46,21 +47,37 @@ fun AdaptiveMainScreen(
|
||||
)
|
||||
}
|
||||
|
||||
val isQueryEmpty = searchState.text.isEmpty()
|
||||
val isShowingDetail = scaffoldState.targetState.primary == PaneAdaptedValue.Expanded
|
||||
val isTwoPane =
|
||||
scaffoldState.targetState.primary == PaneAdaptedValue.Expanded &&
|
||||
scaffoldState.targetState.secondary == PaneAdaptedValue.Expanded
|
||||
val listEndPadding = if (isTwoPane) 8.dp else 16.dp
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
val searchBar: @Composable () -> Unit = {
|
||||
MainTopBar(
|
||||
searchState = searchState,
|
||||
enabled = canSearch,
|
||||
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
||||
onClearQuery = { onAction(MainAction.ClearQuery) },
|
||||
onNavigateToSettings = onNavigateToSettings
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(
|
||||
if (isSearchBarAtBottom) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.ime))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
if (!isSearchBarAtBottom) {
|
||||
searchBar()
|
||||
}
|
||||
|
||||
ListDetailPaneScaffold(
|
||||
directive = PaneScaffoldDirective.Default.copy(
|
||||
@@ -120,26 +137,21 @@ fun AdaptiveMainScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (isSearchBarAtBottom) {
|
||||
searchBar()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(articleState, isQueryEmpty, isLandscape) {
|
||||
val targetValue = when {
|
||||
isLandscape -> ThreePaneScaffoldValue(
|
||||
primary = PaneAdaptedValue.Expanded,
|
||||
secondary = PaneAdaptedValue.Expanded,
|
||||
tertiary = PaneAdaptedValue.Hidden,
|
||||
)
|
||||
articleState == null -> ThreePaneScaffoldValue(
|
||||
LaunchedEffect(articleState) {
|
||||
val targetValue = if (articleState == null) {
|
||||
ThreePaneScaffoldValue(
|
||||
primary = PaneAdaptedValue.Hidden,
|
||||
secondary = PaneAdaptedValue.Expanded,
|
||||
tertiary = PaneAdaptedValue.Hidden,
|
||||
)
|
||||
isQueryEmpty -> ThreePaneScaffoldValue(
|
||||
primary = PaneAdaptedValue.Expanded,
|
||||
secondary = PaneAdaptedValue.Hidden,
|
||||
tertiary = PaneAdaptedValue.Hidden,
|
||||
)
|
||||
else -> ThreePaneScaffoldValue(
|
||||
} else {
|
||||
ThreePaneScaffoldValue(
|
||||
primary = PaneAdaptedValue.Expanded,
|
||||
secondary = PaneAdaptedValue.Expanded,
|
||||
tertiary = PaneAdaptedValue.Hidden,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.example.research.ui.main
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
@@ -20,6 +22,7 @@ import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.example.research.core.domain.model.IndexEntry
|
||||
import com.example.research.feature.download.config.DictionaryConfig
|
||||
import com.example.research.core.domain.model.SearchBarPosition
|
||||
import com.example.research.feature.download.model.DownloadState
|
||||
import com.example.research.feature.search.SearchAction
|
||||
import com.example.research.feature.search.SearchViewModel
|
||||
@@ -40,6 +43,7 @@ fun MainRoute(
|
||||
|
||||
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
val isSearchBarAtBottom = settingsState.searchBarPosition == SearchBarPosition.BOTTOM
|
||||
|
||||
val scrollPosition = remember(searchViewModel) {
|
||||
searchViewModel.scrollPosition.value
|
||||
@@ -98,6 +102,7 @@ fun MainRoute(
|
||||
searchState = searchState,
|
||||
canSearch = canSearch,
|
||||
isLandscape = isLandscape,
|
||||
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||
results = results,
|
||||
errorMessage = errorMessage,
|
||||
showDictionaryName = showDictionaryName,
|
||||
@@ -121,6 +126,7 @@ fun MainRoute(
|
||||
MainScreen(
|
||||
searchState = searchState,
|
||||
canSearch = canSearch,
|
||||
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||
results = results,
|
||||
errorMessage = errorMessage,
|
||||
showDictionaryName = showDictionaryName,
|
||||
@@ -137,6 +143,7 @@ fun MainRoute(
|
||||
internal fun MainScreen(
|
||||
searchState: TextFieldState,
|
||||
canSearch: Boolean,
|
||||
isSearchBarAtBottom: Boolean,
|
||||
results: LazyPagingItems<IndexEntry>,
|
||||
errorMessage: String?,
|
||||
showDictionaryName: Boolean,
|
||||
@@ -145,16 +152,44 @@ internal fun MainScreen(
|
||||
onArticleClick: (IndexEntry) -> Unit,
|
||||
onNavigateToSettings: () -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainTopBar(
|
||||
val searchBar: @Composable () -> Unit = {
|
||||
MainTopBar(
|
||||
searchState = searchState,
|
||||
enabled = canSearch,
|
||||
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
||||
onClearQuery = { onAction(MainAction.ClearQuery) },
|
||||
onNavigateToSettings = onNavigateToSettings
|
||||
)
|
||||
}
|
||||
|
||||
if (isSearchBarAtBottom) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
|
||||
) {
|
||||
SearchResultsPane(
|
||||
searchState = searchState,
|
||||
enabled = canSearch,
|
||||
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
||||
onClearQuery = { onAction(MainAction.ClearQuery) },
|
||||
onNavigateToSettings = onNavigateToSettings
|
||||
results = results,
|
||||
errorMessage = errorMessage,
|
||||
showDictionaryName = showDictionaryName,
|
||||
scrollPosition = scrollPosition,
|
||||
onAction = onAction,
|
||||
onArticleClick = onArticleClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
|
||||
.padding(start = Spacing.contentStart, end = 16.dp),
|
||||
)
|
||||
},
|
||||
searchBar()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { searchBar() },
|
||||
contentWindowInsets = AppWindowInsets.navigationOnly
|
||||
) { paddingValues ->
|
||||
SearchResultsPane(
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
package com.example.research.ui.main.components
|
||||
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -17,30 +28,50 @@ import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import com.example.research.ui.theme.AppWindowInsets
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.research.R
|
||||
import com.example.research.common.ui.theme.dictionaryTitleLarge
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import com.example.research.ui.theme.AppWindowInsets
|
||||
import com.example.research.ui.theme.Spacing
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val TopBarHeight = Spacing.topBarHeight
|
||||
private val TopBarShape = RoundedCornerShape(28.dp)
|
||||
@@ -49,6 +80,7 @@ private val TopBarShape = RoundedCornerShape(28.dp)
|
||||
fun MainTopBar(
|
||||
searchState: TextFieldState,
|
||||
enabled: Boolean,
|
||||
isSearchBarAtBottom: Boolean = false,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClearQuery: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit
|
||||
@@ -57,15 +89,31 @@ fun MainTopBar(
|
||||
val closeIcon = painterResource(R.drawable.ic_close)
|
||||
val settingsIcon = painterResource(R.drawable.ic_settings)
|
||||
val clearLabel = stringResource(R.string.clear_search)
|
||||
var topBarBottomPx by remember { mutableIntStateOf(0) }
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned { topBarBottomPx = it.boundsInWindow().bottom.roundToInt() }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
|
||||
.windowInsetsPadding(
|
||||
if (isSearchBarAtBottom) {
|
||||
AppWindowInsets.horizontalSafeDrawing
|
||||
} else {
|
||||
AppWindowInsets.topAndHorizontalSafeDrawing
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (isSearchBarAtBottom) {
|
||||
Modifier.windowInsetsPadding(AppWindowInsets.bottomNavigation)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
|
||||
@@ -51,6 +51,7 @@ import com.example.research.common.ui.components.CollapsibleHeader
|
||||
import com.example.research.common.ui.components.SectionCard
|
||||
import com.example.research.ui.settings.components.DictionaryManagement
|
||||
import com.example.research.ui.settings.components.LanguageSelectionSection
|
||||
import com.example.research.ui.settings.components.SearchBarPositionSection
|
||||
import com.example.research.ui.settings.components.ThemeSelectionSection
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -238,6 +239,24 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "search_bar", contentType = "settings-section") {
|
||||
CollapsibleSettingsSection(
|
||||
title = stringResource(R.string.search_bar_position),
|
||||
iconRes = R.drawable.ic_search,
|
||||
testTag = "search_bar_section",
|
||||
isExpanded = uiState.isSearchBarExpanded,
|
||||
onToggleExpanded = { onEvent(SettingsUiEvent.SectionToggled("search_bar", !uiState.isSearchBarExpanded)) },
|
||||
) {
|
||||
SearchBarPositionSection(
|
||||
currentPosition = uiState.searchBarPosition,
|
||||
onPositionChanged = { position ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onEvent(SettingsUiEvent.SearchBarPositionChanged(position))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "bottom-spacer", contentType = "spacer") {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.example.research.ui.settings
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.example.research.core.domain.model.AppTheme
|
||||
import com.example.research.core.domain.model.SearchBarPosition
|
||||
import com.example.research.core.domain.model.Dictionary
|
||||
import com.example.research.core.domain.model.DictionarySource
|
||||
import com.example.research.core.domain.model.IndexingProgress
|
||||
@@ -13,8 +14,10 @@ import com.example.research.feature.download.model.DownloadState
|
||||
data class SettingsUiState(
|
||||
val theme: AppTheme = AppTheme.SYSTEM,
|
||||
val language: String = "system",
|
||||
val searchBarPosition: SearchBarPosition = SearchBarPosition.TOP,
|
||||
val isThemeExpanded: Boolean = false,
|
||||
val isLanguageExpanded: Boolean = false,
|
||||
val isSearchBarExpanded: Boolean = false,
|
||||
val isDictionariesExpanded: Boolean = true,
|
||||
val dictionaries: List<Dictionary> = emptyList(),
|
||||
val dictionaryStatus: DictionaryStatus = DictionaryStatus.Unknown,
|
||||
@@ -30,6 +33,7 @@ data class SettingsUiState(
|
||||
sealed interface SettingsUiEvent {
|
||||
data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent
|
||||
data class LanguageChanged(val language: String) : SettingsUiEvent
|
||||
data class SearchBarPositionChanged(val position: SearchBarPosition) : SettingsUiEvent
|
||||
data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent
|
||||
data class DictionaryToggled(val path: String) : SettingsUiEvent
|
||||
data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.example.research.R
|
||||
import com.example.research.common.progress.DictionaryProgressStateHolder
|
||||
import com.example.research.common.util.DateUtils
|
||||
import com.example.research.core.domain.model.AppTheme
|
||||
import com.example.research.core.domain.model.SearchBarPosition
|
||||
import com.example.research.core.domain.model.Dictionary
|
||||
import com.example.research.core.domain.model.DictionarySource
|
||||
import com.example.research.core.domain.model.IndexingProgress
|
||||
@@ -46,11 +47,13 @@ private data class DictionaryStateInputs(
|
||||
private data class OperationStateInputs(
|
||||
val downloadState: DownloadState,
|
||||
val language: String,
|
||||
val searchBarPosition: SearchBarPosition,
|
||||
val importState: ImportState,
|
||||
val dictionaryStatus: DictionaryStatus,
|
||||
val hasCompletedStartupScan: Boolean,
|
||||
val isThemeExpanded: Boolean,
|
||||
val isLanguageExpanded: Boolean,
|
||||
val isSearchBarExpanded: Boolean,
|
||||
val isDictionariesExpanded: Boolean,
|
||||
)
|
||||
|
||||
@@ -64,6 +67,7 @@ class SettingsViewModel(
|
||||
progressStateHolder: DictionaryProgressStateHolder,
|
||||
appVersion: String,
|
||||
initialSectionExpansion: SectionExpansionStates,
|
||||
initialSearchBarPosition: SearchBarPosition,
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
|
||||
@@ -76,7 +80,9 @@ class SettingsViewModel(
|
||||
appVersion = appVersion,
|
||||
isThemeExpanded = initialSectionExpansion.isThemeExpanded,
|
||||
isLanguageExpanded = initialSectionExpansion.isLanguageExpanded,
|
||||
isSearchBarExpanded = initialSectionExpansion.isSearchBarExpanded,
|
||||
isDictionariesExpanded = initialSectionExpansion.isDictionariesExpanded,
|
||||
searchBarPosition = initialSearchBarPosition,
|
||||
)
|
||||
)
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
@@ -152,23 +158,27 @@ class SettingsViewModel(
|
||||
val operationStateInputs = combine(
|
||||
downloadManager.downloadState,
|
||||
language,
|
||||
preferencesManager.searchBarPosition,
|
||||
dictionaryImportManager.importState,
|
||||
dictionaryStatus,
|
||||
hasCompletedStartupScan,
|
||||
preferencesManager.isThemeExpanded,
|
||||
preferencesManager.isLanguageExpanded,
|
||||
preferencesManager.isSearchBarExpanded,
|
||||
preferencesManager.isDictionariesExpanded,
|
||||
) { params: Array<Any> ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
OperationStateInputs(
|
||||
downloadState = params[0] as DownloadState,
|
||||
language = params[1] as String,
|
||||
importState = params[2] as ImportState,
|
||||
dictionaryStatus = params[3] as DictionaryStatus,
|
||||
hasCompletedStartupScan = params[4] as Boolean,
|
||||
isThemeExpanded = params[5] as Boolean,
|
||||
isLanguageExpanded = params[6] as Boolean,
|
||||
isDictionariesExpanded = params[7] as Boolean,
|
||||
searchBarPosition = params[2] as SearchBarPosition,
|
||||
importState = params[3] as ImportState,
|
||||
dictionaryStatus = params[4] as DictionaryStatus,
|
||||
hasCompletedStartupScan = params[5] as Boolean,
|
||||
isThemeExpanded = params[6] as Boolean,
|
||||
isLanguageExpanded = params[7] as Boolean,
|
||||
isSearchBarExpanded = params[8] as Boolean,
|
||||
isDictionariesExpanded = params[9] as Boolean,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -247,6 +257,7 @@ class SettingsViewModel(
|
||||
indexingProgress = IndexingProgress(isIndexing = isIndexing),
|
||||
downloadState = operationState.downloadState,
|
||||
language = operationState.language,
|
||||
searchBarPosition = operationState.searchBarPosition,
|
||||
importState = operationState.importState,
|
||||
dictionaryStatus = operationState.dictionaryStatus,
|
||||
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
|
||||
@@ -255,6 +266,7 @@ class SettingsViewModel(
|
||||
!operationState.importState.isActive,
|
||||
isThemeExpanded = operationState.isThemeExpanded,
|
||||
isLanguageExpanded = operationState.isLanguageExpanded,
|
||||
isSearchBarExpanded = operationState.isSearchBarExpanded,
|
||||
isDictionariesExpanded = operationState.isDictionariesExpanded,
|
||||
appVersion = _uiState.value.appVersion,
|
||||
)
|
||||
@@ -295,6 +307,7 @@ class SettingsViewModel(
|
||||
when (event) {
|
||||
is SettingsUiEvent.ThemeChanged -> setTheme(event.theme)
|
||||
is SettingsUiEvent.LanguageChanged -> saveLanguage(event.language)
|
||||
is SettingsUiEvent.SearchBarPositionChanged -> saveSearchBarPosition(event.position)
|
||||
is SettingsUiEvent.SectionToggled -> saveSectionExpanded(event.key, event.isExpanded)
|
||||
is SettingsUiEvent.DictionaryToggled -> toggleDictionaryActive(event.path)
|
||||
is SettingsUiEvent.DictionaryDeleted -> deleteDictionary(event.dictionary)
|
||||
@@ -367,6 +380,12 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveSearchBarPosition(position: SearchBarPosition) {
|
||||
viewModelScope.launch {
|
||||
preferencesManager.saveSearchBarPosition(position)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLanguage(languageCode: String) {
|
||||
viewModelScope.launch {
|
||||
preferencesManager.saveLanguage(languageCode)
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.example.research.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.research.R
|
||||
import com.example.research.common.ui.components.OutlinedChoiceButton
|
||||
import com.example.research.core.domain.model.SearchBarPosition
|
||||
|
||||
@Composable
|
||||
fun SearchBarPositionSection(
|
||||
currentPosition: SearchBarPosition,
|
||||
onPositionChanged: (SearchBarPosition) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
SearchBarPosition.entries.forEach { position ->
|
||||
val isSelected = currentPosition == position
|
||||
OutlinedChoiceButton(
|
||||
text = when (position) {
|
||||
SearchBarPosition.TOP -> stringResource(R.string.search_bar_position_top)
|
||||
SearchBarPosition.BOTTOM -> stringResource(R.string.search_bar_position_bottom)
|
||||
},
|
||||
onClick = { onPositionChanged(position) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTag("search_bar_${position.name}"),
|
||||
selected = isSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,20 @@ object AppWindowInsets {
|
||||
@Composable
|
||||
get() = WindowInsets.navigationBars.union(WindowInsets.ime)
|
||||
|
||||
val statusBarsAndIme: WindowInsets
|
||||
@Composable
|
||||
get() = WindowInsets.statusBars.union(WindowInsets.ime)
|
||||
|
||||
val fullScreen: WindowInsets
|
||||
@Composable
|
||||
get() = WindowInsets.statusBars
|
||||
.union(WindowInsets.navigationBars)
|
||||
.union(WindowInsets.displayCutout)
|
||||
|
||||
val bottomNavigation: WindowInsets
|
||||
@Composable
|
||||
get() = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
|
||||
|
||||
val navigationOnly: WindowInsets
|
||||
@Composable
|
||||
get() = WindowInsets.navigationBars
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M7,9H2V7h5v2zM7,12H2v2h5v-2zM20.59,19l-3.83,-3.83c-0.8,0.52 -1.74,0.83 -2.76,0.83 -2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5c0,1.02 -0.31,1.96 -0.83,2.75L22,17.59 20.59,19zM17,11c0,-1.65 -1.35,-3 -3,-3s-3,1.35 -3,3 1.35,3 3,3 3,-1.35 3,-3zM2,19h10v-2H2v2z" />
|
||||
</vector>
|
||||
@@ -12,6 +12,9 @@
|
||||
<string name="language_english">Английский</string>
|
||||
<string name="language_russian">Русский</string>
|
||||
<string name="language_system">Системный</string>
|
||||
<string name="search_bar_position">Строка поиска</string>
|
||||
<string name="search_bar_position_top">Наверху</string>
|
||||
<string name="search_bar_position_bottom">Внизу</string>
|
||||
<string name="clear_search">Очистить поиск</string>
|
||||
<string name="article_return_to_search">Вернуться к поиску</string>
|
||||
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<string name="language_english">English</string>
|
||||
<string name="language_russian">Russian</string>
|
||||
<string name="language_system">System</string>
|
||||
<string name="search_bar_position">Search bar</string>
|
||||
<string name="search_bar_position_top">Top</string>
|
||||
<string name="search_bar_position_bottom">Bottom</string>
|
||||
<string name="clear_search">Clear search</string>
|
||||
<string name="article_return_to_search">Return to search</string>
|
||||
<string name="error_checking_dictionaries">Failed to check dictionary status</string>
|
||||
|
||||
Reference in new issue
Block a user