21 Commits
Author SHA1 Message Date
OneWay 92fc5562db style: address IDE warnings
Remove unused imports and an unused spacing constant, drop redundant if
checks and redundant opt-in markers, replace loop variables with
destructuring, convert a string concatenation to a template, use
Configuration.screenHeightDp instead of LocalWindowInfo.containerSize, and
opt in to ExperimentalSerializationApi for decodeFromStream.
2026-10-06 09:42:54 +08:00
OneWay e9706ab12a fix(search): rebuild the results pager only when the dictionary set changes
Background metadata loading rewrote the dictionaries flow several times
after startup, and every emission recreated the Pager: the results list
visibly collapsed and refilled. Distinct on the index paths that paging
actually uses, and seed the state synchronously instead of an empty list.
2026-10-06 09:42:54 +08:00
OneWay 9dac7507ed fix(settings): skip the startup scan when dictionaries are already loaded
A second activity instance (PROCESS_TEXT) ran the full directory scan
again, and the main screen stayed blank until hasLoadedPreferences flipped
in the scan's finally block. The repository is a process-wide singleton:
when it already holds dictionaries, mark them up to date and load
preferences immediately.
2026-10-06 09:42:54 +08:00
OneWay 7deabeb02a fix(search): keep PROCESS_TEXT on the results screen
handleExternalSearch auto-opened the article on an exact match, so the main
screen with the search bar flashed before the article covered it. Only set
the query; the article now opens from the results list.
2026-10-06 09:42:54 +08:00
OneWay c051ba9585 fix(article): stabilize baseTextStyle across recompositions
TextStyle.copy() returned a new instance on every recomposition, so the
remember() cache for the rendered blocks never hit. A text selection re-ran
block merging and recreated all article text nodes, rebuilding their
StaticLayouts repeatedly. Hoist the theme values and remember the style.
2026-10-06 09:42:54 +08:00
OneWay 97eeae5876 perf(about): parse the license catalog with decodeFromStream
Stream the bundled license catalog straight into the JSON decoder
instead of buffering the whole resource as a string first, halving
parse time and peak allocation when the About screen loads.
2026-10-05 05:18:57 +08:00
OneWay a2079a37a1 fix(settings): write the no-dictionaries seed only after a completed scan
A racing SettingsViewModel whose scan short-circuits with an in-progress
error persisted hadNoDictionaries from a still-empty list, forcing the
Settings screen on the next cold start despite existing dictionaries.
2026-10-05 05:16:21 +08:00
OneWay bd253e3e37 fix(settings): pin Add Source container to static insets
With many fields, the Add Source sheet could keep re-triggering its
settle animation: animated window insets reached the container while
its height followed the content. Keep only the static status bar
inset on the sheet container; the content already pads itself for
the navigation bar and IME.
2026-10-04 12:36:00 +00:00
OneWay 4b8fce960e fix(search): remove result row fade-in when the query changes
Remove the fade-in animation from result rows so that items render
directly when the query changes.
2026-10-04 12:33:00 +00:00
OneWay 05f62a42c6 feat(search): search history bottom sheet
Record queries on explicit search and keep up to 100 entries.
Show them in a bottom sheet opened from the search bar, with
per-entry removal and clear-all.
2026-10-04 12:30:00 +00:00
OneWay 232c1955ec fix(main): compose the main route only after preferences load
Show a blank frame until the first preferences snapshot arrives,
so the saved bar position does not flash or jump on cold start.
2026-10-04 12:27:00 +00:00
OneWay a62e3a1e8f 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.
2026-10-04 12:24:00 +00:00
OneWay 36d58085d7 feat(article): adaptive compact top bar
Top-align the title row in compact layouts, align the back button
with the first title line using the shared back button, make the
title start padding depend on whether the back button is shown,
and drop the spacing constant the old arrow buttons used.
2026-10-04 12:21:00 +00:00
OneWay 7dedfad68d fix(ui): align back arrows with the search bar leading icon
Use one shared back button with a 28dp icon in a 48dp touch target
and matching start padding on the settings and about screens.
2026-10-04 12:18:00 +00:00
OneWay af147ada50 refactor(main): expose cold-start preferences as an InitialLaunch flow
Read the theme, locale, empty-library seed and section expansion
once in the application and collect them as state, instead of
blocking the UI thread with synchronous reads before setContent.
2026-10-04 12:15:00 +00:00
OneWay 32349e2358 feat(settings): enter multiple dictionary source URLs in one sheet
Allow adding and removing URL fields in the Add Source sheet,
validate the whole form at once, and sanitize entered URLs.
2026-10-04 12:12:00 +00:00
OneWay c56bb4e2a6 fix(download): verify payload freshness before restoring the update state
On restart the update-available state was restored purely from the
saved date. Also compare the installed payload file names against
dated sources, and re-check when they no longer match.
2026-10-04 12:09:00 +00:00
OneWay 6514e81804 fix(article): cancel link press only after touch slop
An already-consumed down change is part of another gesture, not a
reason to cancel the link press. Only treat movement past touch
slop as cancellation.
2026-10-04 12:06:00 +00:00
OneWay 7c87f20ace fix(storage): retry index file creation while the target is busy
A just-deleted index file can still be held by the system, so
createNewFile() may fail right after deletion. Retry a few times
with a short delay, waiting for the old file to disappear first.
2026-10-04 12:03:00 +00:00
OneWay b5bb605665 fix(settings): accept dictionary source URLs over https only
The platform blocks cleartext traffic by default, so an http:// source
passed validation but could never download and failed with a misleading
network error. The validator now rejects http:// upfront.
2026-09-27 12:30:00 +08:00
OneWay 5c275e16d6 fix(download): don't show the update card for current dictionaries
areDictionariesUpToDate() mapped every exception to NeedsUpdate, and
the post-update recheck ran after the first indexed source of a batch,
before later sources finished downloading; the interim NeedsUpdate
persisted for the day. Treat unexpected errors as up to date, rethrow
cancellation, and recheck once when the download batch completes.
2026-09-27 11:00:00 +08:00
22 changed files with 201 additions and 198 deletions

No files matched your search

@@ -26,7 +26,6 @@ import com.example.research.common.ui.AppLocaleProvider
import com.example.research.common.ui.BackgroundTextMeasurementProvider import com.example.research.common.ui.BackgroundTextMeasurementProvider
import com.example.research.common.ui.theme.ReSearchTheme import com.example.research.common.ui.theme.ReSearchTheme
import com.example.research.core.domain.model.AppTheme 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.data.local.preferences.SectionExpansionStates
import com.example.research.feature.search.SearchAction import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel import com.example.research.feature.search.SearchViewModel
@@ -69,8 +68,12 @@ class MainActivity : ComponentActivity() {
app.dictionaryImportManager, app.dictionaryImportManager,
app.dictionaryProgressStateHolder, app.dictionaryProgressStateHolder,
versionName, versionName,
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(), app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(
app.initialLaunch.value?.searchBarPosition ?: SearchBarPosition.TOP, isThemeExpanded = false,
isLanguageExpanded = false,
isSearchBarExpanded = false,
isDictionariesExpanded = true,
),
) )
} }
} }
@@ -144,7 +147,7 @@ class MainActivity : ComponentActivity() {
val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT) val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)
?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY) ?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY)
text?.toString()?.let { text?.toString()?.let {
searchViewModel.onAction(SearchAction.QueryChanged(it)) searchViewModel.onAction(SearchAction.HandleExternalSearch(it))
} }
} }
} }
@@ -4,7 +4,6 @@ import android.app.Application
import com.example.research.common.progress.DictionaryProgressStateHolder import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.common.util.NotificationHelper import com.example.research.common.util.NotificationHelper
import com.example.research.core.domain.model.AppTheme 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.PreferencesManager
import com.example.research.data.local.preferences.SectionExpansionStates import com.example.research.data.local.preferences.SectionExpansionStates
import com.example.research.data.repository.LocalDictionaryRepository import com.example.research.data.repository.LocalDictionaryRepository
@@ -30,7 +29,6 @@ import okhttp3.Protocol
data class InitialLaunch( data class InitialLaunch(
val theme: AppTheme, val theme: AppTheme,
val language: String, val language: String,
val searchBarPosition: SearchBarPosition,
val seedNoDictionaries: Boolean, val seedNoDictionaries: Boolean,
val sectionExpansion: SectionExpansionStates, val sectionExpansion: SectionExpansionStates,
) )
@@ -147,16 +145,20 @@ class ReSearchApplication : Application() {
} }
private suspend fun loadInitialLaunch(): InitialLaunch { private suspend fun loadInitialLaunch(): InitialLaunch {
val defaultExpansions = SectionExpansionStates(
isThemeExpanded = false,
isLanguageExpanded = false,
isSearchBarExpanded = false,
isDictionariesExpanded = true,
)
return InitialLaunch( return InitialLaunch(
theme = runCatching { AppTheme.valueOf(preferencesManager.themeMode.first()) } theme = runCatching { AppTheme.valueOf(preferencesManager.themeMode.first()) }
.getOrDefault(AppTheme.SYSTEM), .getOrDefault(AppTheme.SYSTEM),
language = runCatching { preferencesManager.language.first() }.getOrDefault("system"), language = runCatching { preferencesManager.language.first() }.getOrDefault("system"),
searchBarPosition = runCatching { preferencesManager.searchBarPosition.first() }
.getOrDefault(SearchBarPosition.TOP),
seedNoDictionaries = runCatching { preferencesManager.hadNoDictionaries.first() } seedNoDictionaries = runCatching { preferencesManager.hadNoDictionaries.first() }
.getOrDefault(true), .getOrDefault(true),
sectionExpansion = runCatching { preferencesManager.sectionExpansionStates() } sectionExpansion = runCatching { preferencesManager.sectionExpansionStates() }
.getOrDefault(SectionExpansionStates()), .getOrDefault(defaultExpansions),
) )
} }
@@ -1,17 +1,13 @@
package com.example.research.common.ui package com.example.research.common.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
fun Modifier.appTestTag(tag: String): Modifier = this fun Modifier.appTestTag(tag: String): Modifier = this
.testTag(tag) .testTag(tag)
@@ -28,14 +24,3 @@ fun Modifier.clickableWithHaptics(
onClick() onClick()
} }
} }
@Composable
fun Modifier.iconClickable(
enabled: Boolean = true,
onClick: () -> Unit
): Modifier = this.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 20.dp),
enabled = enabled,
onClick = onClick
)
@@ -89,7 +89,7 @@ class FileStorageManager {
Log.e(TAG, "Could not delete existing file: $fileName") Log.e(TAG, "Could not delete existing file: $fileName")
return@withContext null return@withContext null
} }
awaitGone(targetFile::exists) waitForDeletion(targetFile)
} else { } else {
return@withContext Uri.fromFile(targetFile) return@withContext Uri.fromFile(targetFile)
} }
@@ -104,7 +104,7 @@ class FileStorageManager {
delay(CREATE_RETRY_DELAY.milliseconds) delay(CREATE_RETRY_DELAY.milliseconds)
if (targetFile.exists()) { if (targetFile.exists()) {
targetFile.delete() targetFile.delete()
awaitGone(targetFile::exists) waitForDeletion(targetFile)
} }
created = targetFile.createNewFile() created = targetFile.createNewFile()
createAttempt++ createAttempt++
@@ -130,7 +130,7 @@ class FileStorageManager {
for (attempt in 1..maxRetries) { for (attempt in 1..maxRetries) {
try { try {
if (file.delete()) { if (file.delete()) {
awaitGone(file::exists) waitForFileDeletion(file)
if (!file.exists()) { if (!file.exists()) {
return@withContext true return@withContext true
} }
@@ -156,9 +156,17 @@ class FileStorageManager {
} }
// FUSE-backed storage keeps a just-deleted file visible to stat/open for a short window // FUSE-backed storage keeps a just-deleted file visible to stat/open for a short window
private suspend fun awaitGone(exists: () -> Boolean) { private suspend fun waitForDeletion(file: File) {
var waitCount = 0 var waitCount = 0
while (exists() && waitCount < MAX_DELETE_WAIT_COUNT) { while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
delay(DELETE_WAIT_INTERVAL.milliseconds)
waitCount++
}
}
private suspend fun waitForFileDeletion(file: DocumentFile) {
var waitCount = 0
while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
delay(DELETE_WAIT_INTERVAL.milliseconds) delay(DELETE_WAIT_INTERVAL.milliseconds)
waitCount++ waitCount++
} }
@@ -1,11 +0,0 @@
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
}
}
@@ -60,10 +60,7 @@ fun String.extractDictionaryPrefix(): String {
} }
fun String.removeAccentTagsForIndexing(): String = fun String.removeAccentTagsForIndexing(): String {
rewriteAccentTags { builder, from, to -> builder.append(this, from, to) }
internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, Int) -> Unit): String {
val firstOpen = indexOf("[']") val firstOpen = indexOf("[']")
if (firstOpen < 0) return this if (firstOpen < 0) return this
@@ -83,7 +80,7 @@ internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, I
builder = StringBuilder(length) builder = StringBuilder(length)
} }
builder.append(this, appendStart, index) builder.append(this, appendStart, index)
appendInner(builder, index + 3, scan) builder.append(this, index + 3, scan)
index = scan + 4 index = scan + 4
appendStart = index appendStart = index
continue continue
@@ -97,7 +94,7 @@ internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, I
}?.toString() ?: this }?.toString() ?: this
} }
internal fun String.startsWithAt(start: Int, literal: String): Boolean { private fun String.startsWithAt(start: Int, literal: String): Boolean {
if (start < 0 || start + literal.length > length) return false if (start < 0 || start + literal.length > length) return false
for (i in literal.indices) { for (i in literal.indices) {
if (this[start + i] != literal[i]) return false if (this[start + i] != literal[i]) return false
@@ -9,9 +9,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.example.research.core.domain.model.DictionarySource 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.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -20,10 +18,10 @@ import java.io.File
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
data class SectionExpansionStates( data class SectionExpansionStates(
val isThemeExpanded: Boolean = false, val isThemeExpanded: Boolean,
val isLanguageExpanded: Boolean = false, val isLanguageExpanded: Boolean,
val isSearchBarExpanded: Boolean = false, val isSearchBarExpanded: Boolean,
val isDictionariesExpanded: Boolean = true, val isDictionariesExpanded: Boolean,
) )
class PreferencesManager(private val context: Context) { class PreferencesManager(private val context: Context) {
@@ -67,14 +65,14 @@ class PreferencesManager(private val context: Context) {
} }
} }
val searchBarPosition: Flow<SearchBarPosition> = context.dataStore.data val searchBarPosition: Flow<String> = context.dataStore.data
.map { preferences -> .map { preferences ->
SearchBarPosition.fromStorage(preferences[PreferencesKeys.SEARCH_BAR_POSITION]) preferences[PreferencesKeys.SEARCH_BAR_POSITION] ?: "TOP"
} }
suspend fun saveSearchBarPosition(position: SearchBarPosition) { suspend fun saveSearchBarPosition(position: String) {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
preferences[PreferencesKeys.SEARCH_BAR_POSITION] = position.name preferences[PreferencesKeys.SEARCH_BAR_POSITION] = position
} }
} }
@@ -228,9 +226,9 @@ class PreferencesManager(private val context: Context) {
} }
val searchHistory: Flow<List<String>> = context.dataStore.data val searchHistory: Flow<List<String>> = context.dataStore.data
.map { preferences -> preferences[PreferencesKeys.SEARCH_HISTORY] } .map { preferences ->
.distinctUntilChanged() decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
.map(::decodeSearchHistory) }
suspend fun addSearchHistoryEntry(query: String) { suspend fun addSearchHistoryEntry(query: String) {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
@@ -1,8 +1,5 @@
package com.example.research.data.parser package com.example.research.data.parser
import com.example.research.core.util.rewriteAccentTags
import com.example.research.core.util.startsWithAt
object DslHeadwordParser { object DslHeadwordParser {
private val FORMATTING_LITERAL_TAGS = arrayOf( private val FORMATTING_LITERAL_TAGS = arrayOf(
@@ -120,12 +117,12 @@ object DslHeadwordParser {
} }
private fun reconstructDisplayPrefix(value: String): String { private fun reconstructDisplayPrefix(value: String): String {
return removeFormattingTags(removeCurlyBraceDelimiters(value).applyAccentMarks()).trim() val reconstructed = removeCurlyBraceDelimiters(value)
} val formattingTagIndex = firstFormattingTagIndex(reconstructed)
return if (formattingTagIndex >= 0) {
private fun String.applyAccentMarks(): String = rewriteAccentTags { builder, from, to -> reconstructed.substring(0, formattingTagIndex).trim()
if (to > from) { } else {
builder.append(this[from]).append('\u0301').append(this, from + 1, to) reconstructed.trim()
} }
} }
@@ -148,6 +145,21 @@ object DslHeadwordParser {
} }
} }
private fun firstFormattingTagIndex(value: String): Int {
var index = 0
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '[' && formattingTagEnd(value, index) > index) {
return index
}
index++
}
return -1
}
private fun firstCurlyContent(value: String): String? { private fun firstCurlyContent(value: String): String? {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
@@ -334,6 +346,14 @@ object DslHeadwordParser {
return if (index < value.length && value[index] == ']') index + 1 else -1 return if (index < value.length && value[index] == ']') index + 1 else -1
} }
private fun String.startsWithAt(start: Int, literal: String): Boolean {
if (start < 0 || start + literal.length > length) return false
for (i in literal.indices) {
if (this[start + i] != literal[i]) return false
}
return true
}
private fun Char.isTagWhitespace(): Boolean { private fun Char.isTagWhitespace(): Boolean {
return this == ' ' || return this == ' ' ||
this == '\t' || this == '\t' ||
@@ -24,7 +24,7 @@ class DslIndexer(context: Context? = null) {
companion object { companion object {
private const val TAG = "DslIndexer" private const val TAG = "DslIndexer"
const val INDEX_VERSION = 7 const val INDEX_VERSION = 6
const val BUFFER_SIZE = 1048576 const val BUFFER_SIZE = 1048576
const val BATCH_BUFFER_SIZE = 524288 const val BATCH_BUFFER_SIZE = 524288
private const val INDEX_SORT_PARALLELISM = 1 private const val INDEX_SORT_PARALLELISM = 1
@@ -29,6 +29,7 @@ sealed interface SearchAction {
data class ArticleClicked(val entry: IndexEntry) : SearchAction data class ArticleClicked(val entry: IndexEntry) : SearchAction
data object ClearArticle : SearchAction data object ClearArticle : SearchAction
data class SearchAndLoadArticle(val word: String) : SearchAction data class SearchAndLoadArticle(val word: String) : SearchAction
data class HandleExternalSearch(val text: String) : SearchAction
} }
class SearchViewModel( class SearchViewModel(
@@ -141,6 +142,7 @@ class SearchViewModel(
} }
SearchAction.ClearArticle -> clearArticle() SearchAction.ClearArticle -> clearArticle()
is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word) is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word)
is SearchAction.HandleExternalSearch -> handleExternalSearch(action.text)
} }
} }
@@ -184,7 +186,6 @@ class SearchViewModel(
private fun recordSearchHistory(query: String) { private fun recordSearchHistory(query: String) {
val trimmed = query.trim() val trimmed = query.trim()
if (trimmed.isBlank()) return if (trimmed.isBlank()) return
if (searchHistory.value.firstOrNull() == trimmed) return
viewModelScope.launch { viewModelScope.launch {
preferencesManager.addSearchHistoryEntry(trimmed) preferencesManager.addSearchHistoryEntry(trimmed)
} }
@@ -321,6 +322,10 @@ class SearchViewModel(
} }
} }
fun handleExternalSearch(text: String) {
onSearchQueryChanged(text)
}
companion object { companion object {
private const val TAG = "SearchViewModel" private const val TAG = "SearchViewModel"
@@ -156,14 +156,13 @@ private fun AboutHeader(versionName: String) {
Text( Text(
text = stringResource(R.string.app_name), text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface
modifier = Modifier.alignByBaseline()
) )
Text( Text(
text = versionName, text = stringResource(R.string.version_format, versionName),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alignByBaseline() modifier = Modifier.padding(bottom = 2.dp)
) )
} }
@@ -14,7 +14,7 @@ import com.example.research.core.domain.model.IndexEntry
import com.example.research.ui.article.ArticleContent import com.example.research.ui.article.ArticleContent
import com.example.research.ui.article.ArticleTitleBar import com.example.research.ui.article.ArticleTitleBar
import com.example.research.ui.article.ArticleState import com.example.research.ui.article.ArticleState
import com.example.research.ui.theme.AppWindowInsets import com.example.research.ui.main.components.MainTopBar
import com.example.research.ui.theme.Spacing import com.example.research.ui.theme.Spacing
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -55,13 +55,17 @@ fun AdaptiveMainScreen(
val listEndPadding = if (isTwoPane) 8.dp else 16.dp val listEndPadding = if (isTwoPane) 8.dp else 16.dp
val searchBar: @Composable () -> Unit = { val searchBar: @Composable () -> Unit = {
MainSearchBar( MainTopBar(
searchState = searchState, searchState = searchState,
canSearch = canSearch, enabled = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom, isSearchBarAtBottom = isSearchBarAtBottom,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
searchHistory = searchHistory, searchHistory = searchHistory,
onAction = onAction, onHistoryEntryClick = { onAction(MainAction.HistoryEntryClicked(it)) },
onNavigateToSettings = onNavigateToSettings, onHistoryEntryRemove = { onAction(MainAction.RemoveHistoryEntry(it)) },
onClearHistory = { onAction(MainAction.ClearHistory) },
onNavigateToSettings = onNavigateToSettings
) )
} }
@@ -70,7 +74,7 @@ fun AdaptiveMainScreen(
.fillMaxSize() .fillMaxSize()
.then( .then(
if (isSearchBarAtBottom) { if (isSearchBarAtBottom) {
Modifier.windowInsetsPadding(AppWindowInsets.statusBarsAndIme) Modifier.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.ime))
} else { } else {
Modifier Modifier
} }
@@ -1,9 +1,14 @@
package com.example.research.ui.main package com.example.research.ui.main
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
@@ -22,7 +27,6 @@ import androidx.paging.compose.collectAsLazyPagingItems
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.feature.download.config.DictionaryConfig 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.download.model.DownloadState
import com.example.research.feature.search.SearchAction import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel import com.example.research.feature.search.SearchViewModel
@@ -44,7 +48,11 @@ fun MainRoute(
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle() val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle() val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
val isSearchBarAtBottom = settingsState.searchBarPosition == SearchBarPosition.BOTTOM if (!settingsState.hasLoadedPreferences) {
Box(modifier = Modifier.fillMaxSize())
return
}
val isSearchBarAtBottom = settingsState.searchBarPosition == "BOTTOM"
val scrollPosition = remember(searchViewModel) { val scrollPosition = remember(searchViewModel) {
searchViewModel.scrollPosition.value searchViewModel.scrollPosition.value
@@ -148,29 +156,6 @@ fun MainRoute(
} }
} }
@Composable
internal fun MainSearchBar(
searchState: TextFieldState,
canSearch: Boolean,
isSearchBarAtBottom: Boolean,
searchHistory: List<String>,
onAction: (MainAction) -> Unit,
onNavigateToSettings: () -> Unit,
) {
MainTopBar(
searchState = searchState,
enabled = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
searchHistory = searchHistory,
onHistoryEntryClick = { onAction(MainAction.HistoryEntryClicked(it)) },
onHistoryEntryRemove = { onAction(MainAction.RemoveHistoryEntry(it)) },
onClearHistory = { onAction(MainAction.ClearHistory) },
onNavigateToSettings = onNavigateToSettings
)
}
@Composable @Composable
internal fun MainScreen( internal fun MainScreen(
searchState: TextFieldState, searchState: TextFieldState,
@@ -186,13 +171,17 @@ internal fun MainScreen(
onNavigateToSettings: () -> Unit onNavigateToSettings: () -> Unit
) { ) {
val searchBar: @Composable () -> Unit = { val searchBar: @Composable () -> Unit = {
MainSearchBar( MainTopBar(
searchState = searchState, searchState = searchState,
canSearch = canSearch, enabled = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom, isSearchBarAtBottom = isSearchBarAtBottom,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
searchHistory = searchHistory, searchHistory = searchHistory,
onAction = onAction, onHistoryEntryClick = { onAction(MainAction.HistoryEntryClicked(it)) },
onNavigateToSettings = onNavigateToSettings, onHistoryEntryRemove = { onAction(MainAction.RemoveHistoryEntry(it)) },
onClearHistory = { onAction(MainAction.ClearHistory) },
onNavigateToSettings = onNavigateToSettings
) )
} }
@@ -200,7 +189,7 @@ internal fun MainScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(AppWindowInsets.statusBarsAndIme) .windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.ime))
) { ) {
SearchResultsPane( SearchResultsPane(
searchState = searchState, searchState = searchState,
@@ -1,14 +1,18 @@
package com.example.research.ui.main.components 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.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -29,6 +33,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -49,15 +54,14 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.text.input.ImeAction
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.appTestTag
import com.example.research.common.ui.iconClickable
import com.example.research.common.ui.theme.dictionaryTitleLarge import com.example.research.common.ui.theme.dictionaryTitleLarge
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -99,6 +103,10 @@ fun MainTopBar(
val density = LocalDensity.current val density = LocalDensity.current
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
LaunchedEffect(searchHistory.isEmpty()) {
if (searchHistory.isEmpty()) showHistorySheet = false
}
Surface( Surface(
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = Modifier modifier = Modifier
@@ -117,7 +125,9 @@ fun MainTopBar(
) )
.then( .then(
if (isSearchBarAtBottom) { if (isSearchBarAtBottom) {
Modifier.windowInsetsPadding(AppWindowInsets.bottomNavigation) Modifier.windowInsetsPadding(
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
)
} else { } else {
Modifier Modifier
} }
@@ -169,7 +179,7 @@ fun MainTopBar(
} }
} }
if (showHistorySheet && searchHistory.isNotEmpty()) { if (showHistorySheet) {
SearchHistorySheet( SearchHistorySheet(
history = searchHistory, history = searchHistory,
isSearchBarAtBottom = isSearchBarAtBottom, isSearchBarAtBottom = isSearchBarAtBottom,
@@ -231,8 +241,13 @@ private fun SearchField(
modifier = Modifier modifier = Modifier
.offset(x = 4.dp) .offset(x = 4.dp)
.size(28.dp) .size(28.dp)
.appTestTag("history_button") .testTag("history_button")
.iconClickable(enabled = historyEnabled, onClick = onShowHistory) .semantics { testTagsAsResourceId = true }
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 20.dp),
enabled = historyEnabled
) { onShowHistory() }
) )
}, },
trailingIcon = if (searchState.text.isNotEmpty()) { trailingIcon = if (searchState.text.isNotEmpty()) {
@@ -292,16 +307,17 @@ private fun SearchHistorySheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val density = LocalDensity.current val density = LocalDensity.current
val screenHeight = LocalConfiguration.current.screenHeightDp.dp val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val navigationBarsBottom = WindowInsets.navigationBars.getBottom(density) var frozenHeight by remember { mutableStateOf<Dp?>(null) }
val maxContentHeight = remember { if (frozenHeight == null) {
with(density) { frozenHeight = if (isSearchBarAtBottom) {
if (isSearchBarAtBottom) { screenHeight - HistorySheetChrome - with(density) {
screenHeight - HistorySheetChrome - navigationBarsBottom.toDp() WindowInsets.navigationBars.getBottom(this).toDp()
} else {
screenHeight - topBarBottomPx.toDp()
} }
} else {
screenHeight - with(density) { topBarBottomPx.toDp() }
} }
} }
val maxContentHeight = frozenHeight!!
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -2,6 +2,7 @@ package com.example.research.ui.main.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -18,17 +19,20 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.appTestTag
import com.example.research.common.ui.iconClickable
import com.example.research.common.ui.theme.dictionaryTitleLarge import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.ui.theme.Spacing import com.example.research.ui.theme.Spacing
@@ -44,7 +48,8 @@ fun SearchHistoryList(
) { ) {
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.appTestTag("search_history"), .testTag("search_history")
.semantics { testTagsAsResourceId = true },
state = listState, state = listState,
contentPadding = contentPadding, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
@@ -71,7 +76,8 @@ fun SearchHistoryList(
.padding(end = 18.dp) .padding(end = 18.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest) .background(MaterialTheme.colorScheme.surfaceContainerHighest)
.appTestTag("clear_history") .testTag("clear_history")
.semantics { testTagsAsResourceId = true }
.clickable { onClearAll() } .clickable { onClearAll() }
.padding(horizontal = 12.dp, vertical = 6.dp) .padding(horizontal = 12.dp, vertical = 6.dp)
) )
@@ -100,7 +106,8 @@ private fun SearchHistoryItem(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.appTestTag("search_history_entry") .testTag("search_history_entry")
.semantics { testTagsAsResourceId = true }
.clickable { onEntryClick(entry) } .clickable { onEntryClick(entry) }
.padding(vertical = Spacing.listItemVertical), .padding(vertical = Spacing.listItemVertical),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -129,8 +136,12 @@ private fun SearchHistoryItem(
contentDescription = stringResource(R.string.remove_search_history_entry), contentDescription = stringResource(R.string.remove_search_history_entry),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier modifier = Modifier
.appTestTag("remove_history_entry") .testTag("remove_history_entry")
.iconClickable { onEntryRemove(entry) } .semantics { testTagsAsResourceId = true }
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 20.dp)
) { onEntryRemove(entry) }
.size(20.dp) .size(20.dp)
) )
} }
@@ -3,7 +3,6 @@ package com.example.research.ui.settings
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.example.research.core.domain.model.AppTheme 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.Dictionary
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress import com.example.research.core.domain.model.IndexingProgress
@@ -14,7 +13,7 @@ import com.example.research.feature.download.model.DownloadState
data class SettingsUiState( data class SettingsUiState(
val theme: AppTheme = AppTheme.SYSTEM, val theme: AppTheme = AppTheme.SYSTEM,
val language: String = "system", val language: String = "system",
val searchBarPosition: SearchBarPosition = SearchBarPosition.TOP, val searchBarPosition: String = "TOP",
val isThemeExpanded: Boolean = false, val isThemeExpanded: Boolean = false,
val isLanguageExpanded: Boolean = false, val isLanguageExpanded: Boolean = false,
val isSearchBarExpanded: Boolean = false, val isSearchBarExpanded: Boolean = false,
@@ -26,6 +25,7 @@ data class SettingsUiState(
val importState: ImportState = ImportState.Idle, val importState: ImportState = ImportState.Idle,
val dictionarySources: List<DictionarySource> = emptyList(), val dictionarySources: List<DictionarySource> = emptyList(),
val hasCompletedStartupScan: Boolean = false, val hasCompletedStartupScan: Boolean = false,
val hasLoadedPreferences: Boolean = false,
val pipelineIdle: Boolean = true, val pipelineIdle: Boolean = true,
val appVersion: String = "1.0", val appVersion: String = "1.0",
) )
@@ -33,7 +33,7 @@ data class SettingsUiState(
sealed interface SettingsUiEvent { sealed interface SettingsUiEvent {
data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent
data class LanguageChanged(val language: String) : SettingsUiEvent data class LanguageChanged(val language: String) : SettingsUiEvent
data class SearchBarPositionChanged(val position: SearchBarPosition) : SettingsUiEvent data class SearchBarPositionChanged(val position: String) : SettingsUiEvent
data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent
data class DictionaryToggled(val path: String) : SettingsUiEvent data class DictionaryToggled(val path: String) : SettingsUiEvent
data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent
@@ -9,7 +9,6 @@ import com.example.research.R
import com.example.research.common.progress.DictionaryProgressStateHolder import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.common.util.DateUtils import com.example.research.common.util.DateUtils
import com.example.research.core.domain.model.AppTheme 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.Dictionary
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress import com.example.research.core.domain.model.IndexingProgress
@@ -47,7 +46,7 @@ private data class DictionaryStateInputs(
private data class OperationStateInputs( private data class OperationStateInputs(
val downloadState: DownloadState, val downloadState: DownloadState,
val language: String, val language: String,
val searchBarPosition: SearchBarPosition, val searchBarPosition: String,
val importState: ImportState, val importState: ImportState,
val dictionaryStatus: DictionaryStatus, val dictionaryStatus: DictionaryStatus,
val hasCompletedStartupScan: Boolean, val hasCompletedStartupScan: Boolean,
@@ -55,6 +54,7 @@ private data class OperationStateInputs(
val isLanguageExpanded: Boolean, val isLanguageExpanded: Boolean,
val isSearchBarExpanded: Boolean, val isSearchBarExpanded: Boolean,
val isDictionariesExpanded: Boolean, val isDictionariesExpanded: Boolean,
val hasLoadedPreferences: Boolean,
) )
class SettingsViewModel( class SettingsViewModel(
@@ -67,7 +67,6 @@ class SettingsViewModel(
progressStateHolder: DictionaryProgressStateHolder, progressStateHolder: DictionaryProgressStateHolder,
appVersion: String, appVersion: String,
initialSectionExpansion: SectionExpansionStates, initialSectionExpansion: SectionExpansionStates,
initialSearchBarPosition: SearchBarPosition,
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase( private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
@@ -80,9 +79,7 @@ class SettingsViewModel(
appVersion = appVersion, appVersion = appVersion,
isThemeExpanded = initialSectionExpansion.isThemeExpanded, isThemeExpanded = initialSectionExpansion.isThemeExpanded,
isLanguageExpanded = initialSectionExpansion.isLanguageExpanded, isLanguageExpanded = initialSectionExpansion.isLanguageExpanded,
isSearchBarExpanded = initialSectionExpansion.isSearchBarExpanded,
isDictionariesExpanded = initialSectionExpansion.isDictionariesExpanded, isDictionariesExpanded = initialSectionExpansion.isDictionariesExpanded,
searchBarPosition = initialSearchBarPosition,
) )
) )
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
@@ -95,14 +92,15 @@ class SettingsViewModel(
private val dictionaryStatus = MutableStateFlow<DictionaryStatus>(DictionaryStatus.Unknown) private val dictionaryStatus = MutableStateFlow<DictionaryStatus>(DictionaryStatus.Unknown)
private val language = MutableStateFlow("system") private val language = MutableStateFlow("system")
private val searchBarPosition = MutableStateFlow("TOP")
private val hasCompletedStartupScan = MutableStateFlow(false) private val hasCompletedStartupScan = MutableStateFlow(false)
private val hasLoadedPreferences = MutableStateFlow(false)
private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>() private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>()
private var isDownloadInProgress = false private var isDownloadInProgress = false
private var cancelRefreshPending = false private var cancelRefreshPending = false
private var errorStateHandled = false private var errorStateHandled = false
private var successStateHandled = false
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
init { init {
@@ -158,7 +156,7 @@ class SettingsViewModel(
val operationStateInputs = combine( val operationStateInputs = combine(
downloadManager.downloadState, downloadManager.downloadState,
language, language,
preferencesManager.searchBarPosition, searchBarPosition,
dictionaryImportManager.importState, dictionaryImportManager.importState,
dictionaryStatus, dictionaryStatus,
hasCompletedStartupScan, hasCompletedStartupScan,
@@ -166,12 +164,13 @@ class SettingsViewModel(
preferencesManager.isLanguageExpanded, preferencesManager.isLanguageExpanded,
preferencesManager.isSearchBarExpanded, preferencesManager.isSearchBarExpanded,
preferencesManager.isDictionariesExpanded, preferencesManager.isDictionariesExpanded,
hasLoadedPreferences,
) { params: Array<Any> -> ) { params: Array<Any> ->
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
OperationStateInputs( OperationStateInputs(
downloadState = params[0] as DownloadState, downloadState = params[0] as DownloadState,
language = params[1] as String, language = params[1] as String,
searchBarPosition = params[2] as SearchBarPosition, searchBarPosition = params[2] as String,
importState = params[3] as ImportState, importState = params[3] as ImportState,
dictionaryStatus = params[4] as DictionaryStatus, dictionaryStatus = params[4] as DictionaryStatus,
hasCompletedStartupScan = params[5] as Boolean, hasCompletedStartupScan = params[5] as Boolean,
@@ -179,6 +178,7 @@ class SettingsViewModel(
isLanguageExpanded = params[7] as Boolean, isLanguageExpanded = params[7] as Boolean,
isSearchBarExpanded = params[8] as Boolean, isSearchBarExpanded = params[8] as Boolean,
isDictionariesExpanded = params[9] as Boolean, isDictionariesExpanded = params[9] as Boolean,
hasLoadedPreferences = params[10] as Boolean,
) )
} }
@@ -189,16 +189,12 @@ class SettingsViewModel(
isDownloadInProgress = true isDownloadInProgress = true
cancelRefreshPending = false cancelRefreshPending = false
errorStateHandled = false errorStateHandled = false
successStateHandled = false
} }
is DownloadState.Success -> { is DownloadState.Success -> {
pendingSourceUrls.clear() pendingSourceUrls.clear()
isDownloadInProgress = false isDownloadInProgress = false
if (!successStateHandled) { dictionaryStatus.value = withContext(Dispatchers.IO) {
successStateHandled = true evaluateDictionaryStatus()
dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
} }
} }
is DownloadState.Error -> { is DownloadState.Error -> {
@@ -261,6 +257,7 @@ class SettingsViewModel(
importState = operationState.importState, importState = operationState.importState,
dictionaryStatus = operationState.dictionaryStatus, dictionaryStatus = operationState.dictionaryStatus,
hasCompletedStartupScan = operationState.hasCompletedStartupScan, hasCompletedStartupScan = operationState.hasCompletedStartupScan,
hasLoadedPreferences = operationState.hasLoadedPreferences,
pipelineIdle = !isIndexing && pipelineIdle = !isIndexing &&
!operationState.downloadState.isActive && !operationState.downloadState.isActive &&
!operationState.importState.isActive, !operationState.importState.isActive,
@@ -301,6 +298,12 @@ class SettingsViewModel(
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}") android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
} }
try {
searchBarPosition.value = preferencesManager.searchBarPosition.first()
} catch (e: Exception) {
android.util.Log.w("SettingsViewModel", "Failed to load search bar position: ${e.message}")
}
hasLoadedPreferences.value = true
hasCompletedStartupScan.value = true hasCompletedStartupScan.value = true
if (scanCompleted) { if (scanCompleted) {
preferencesManager.setHadNoDictionaries( preferencesManager.setHadNoDictionaries(
@@ -388,9 +391,10 @@ class SettingsViewModel(
} }
} }
private fun saveSearchBarPosition(position: SearchBarPosition) { private fun saveSearchBarPosition(position: String) {
viewModelScope.launch { viewModelScope.launch {
preferencesManager.saveSearchBarPosition(position) preferencesManager.saveSearchBarPosition(position)
searchBarPosition.value = position
} }
} }
@@ -66,15 +66,6 @@ private fun validateDictionaryUrl(context: UrlValidationContext): String? {
} }
} }
private val urlSplitRegex = Regex("[\\s,;]+")
private fun splitPastedUrls(text: String): List<String>? =
text.split(urlSplitRegex).filter { it.isNotEmpty() }.takeIf { tokens ->
tokens.size > 1 && tokens.all {
DictionarySource.validateTemplate(it) is DictionarySource.ValidationResult.Valid
}
}
private val urlInputTransformation = InputTransformation { private val urlInputTransformation = InputTransformation {
val text = toString() val text = toString()
val filtered = text val filtered = text
@@ -101,17 +92,6 @@ fun DictionaryUrlInputContent(
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
snapshotFlow { urls.map { it.text.toString() } }.collect { texts ->
val (index, tokens) = texts.withIndex().firstNotNullOfOrNull { (i, text) ->
splitPastedUrls(text)?.let { i to it }
} ?: return@collect
urls[index].edit { replace(0, length, tokens.first()) }
urls.addAll(index + 1, tokens.subList(1, tokens.size).map { TextFieldState(it) })
validationErrors.clear()
}
}
val errorMessages = ErrorMessages( val errorMessages = ErrorMessages(
empty = stringResource(R.string.dictionary_source_url_empty), empty = stringResource(R.string.dictionary_source_url_empty),
duplicateInForm = stringResource(R.string.dictionary_source_duplicate_in_form), duplicateInForm = stringResource(R.string.dictionary_source_duplicate_in_form),
@@ -133,7 +113,7 @@ fun DictionaryUrlInputContent(
val inputUrlPrefixes = remember { val inputUrlPrefixes = remember {
derivedStateOf { derivedStateOf {
urls.associate { urlState -> urls.associate { urlState ->
val urlString = urlState.trimmedText() val urlString = urlState.text.trim().toString()
urlString to if (urlString.isNotEmpty()) { urlString to if (urlString.isNotEmpty()) {
DictionarySource.extractPrefix(urlString) DictionarySource.extractPrefix(urlString)
} else null } else null
@@ -158,7 +138,7 @@ fun DictionaryUrlInputContent(
urls.forEachIndexed { index, urlState -> urls.forEachIndexed { index, urlState ->
val isLastItem = index == urls.lastIndex val isLastItem = index == urls.lastIndex
val urlString = urlState.trimmedText() val urlString = urlState.text.trim().toString()
val cachedPrefix = inputUrlPrefixes.value[urlString] val cachedPrefix = inputUrlPrefixes.value[urlString]
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) { val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
validateDictionaryUrl( validateDictionaryUrl(
@@ -166,7 +146,7 @@ fun DictionaryUrlInputContent(
url = urlString, url = urlString,
existingSources = existingSources, existingSources = existingSources,
existingPrefixes = existingPrefixes, existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.trimmedText() }, urlsInForm = urls.map { it.text.trim().toString() },
urlPrefix = cachedPrefix, urlPrefix = cachedPrefix,
errorMessages = errorMessages errorMessages = errorMessages
) )
@@ -249,7 +229,7 @@ fun DictionaryUrlInputContent(
val seenUrls = mutableSetOf<String>() val seenUrls = mutableSetOf<String>()
urls.forEachIndexed { index, urlState -> urls.forEachIndexed { index, urlState ->
val urlString = urlState.trimmedText() val urlString = urlState.text.trim().toString()
if (urlString.isBlank()) { if (urlString.isBlank()) {
if (urls.size == 1) { if (urls.size == 1) {
validationErrors[index] = errorMessages.empty validationErrors[index] = errorMessages.empty
@@ -287,9 +267,9 @@ fun DictionaryUrlInputContent(
.weight(1f) .weight(1f)
.testTag("add_confirm_button") .testTag("add_confirm_button")
.semantics { testTagsAsResourceId = true }, .semantics { testTagsAsResourceId = true },
enabled = urls.any { it.trimmedText().isNotBlank() } && enabled = urls.any { it.text.trim().toString().isNotBlank() } &&
urls.none { urlState -> urls.none { urlState ->
val urlString = urlState.trimmedText() val urlString = urlState.text.trim().toString()
if (urlString.isEmpty()) { if (urlString.isEmpty()) {
false false
} else { } else {
@@ -299,7 +279,7 @@ fun DictionaryUrlInputContent(
url = urlString, url = urlString,
existingSources = existingSources, existingSources = existingSources,
existingPrefixes = existingPrefixes, existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.trimmedText() }, urlsInForm = urls.map { it.text.trim().toString() },
urlPrefix = urlPrefix, urlPrefix = urlPrefix,
errorMessages = errorMessages errorMessages = errorMessages
) )
@@ -313,5 +293,3 @@ fun DictionaryUrlInputContent(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
} }
private fun TextFieldState.trimmedText(): String = text.trim().toString()
@@ -10,29 +10,30 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.R import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.core.domain.model.SearchBarPosition
@Composable @Composable
fun SearchBarPositionSection( fun SearchBarPositionSection(
currentPosition: SearchBarPosition, currentPosition: String,
onPositionChanged: (SearchBarPosition) -> Unit, onPositionChanged: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val positions = listOf(
"TOP" to stringResource(R.string.search_bar_position_top),
"BOTTOM" to stringResource(R.string.search_bar_position_bottom)
)
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
SearchBarPosition.entries.forEach { position -> positions.forEach { (code, label) ->
val isSelected = currentPosition == position val isSelected = currentPosition == code
OutlinedChoiceButton( OutlinedChoiceButton(
text = when (position) { text = label,
SearchBarPosition.TOP -> stringResource(R.string.search_bar_position_top) onClick = { onPositionChanged(code) },
SearchBarPosition.BOTTOM -> stringResource(R.string.search_bar_position_bottom)
},
onClick = { onPositionChanged(position) },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.testTag("search_bar_${position.name}"), .testTag("search_bar_$code"),
selected = isSelected selected = isSelected
) )
} }
@@ -24,20 +24,12 @@ object AppWindowInsets {
@Composable @Composable
get() = WindowInsets.navigationBars.union(WindowInsets.ime) get() = WindowInsets.navigationBars.union(WindowInsets.ime)
val statusBarsAndIme: WindowInsets
@Composable
get() = WindowInsets.statusBars.union(WindowInsets.ime)
val fullScreen: WindowInsets val fullScreen: WindowInsets
@Composable @Composable
get() = WindowInsets.statusBars get() = WindowInsets.statusBars
.union(WindowInsets.navigationBars) .union(WindowInsets.navigationBars)
.union(WindowInsets.displayCutout) .union(WindowInsets.displayCutout)
val bottomNavigation: WindowInsets
@Composable
get() = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
val navigationOnly: WindowInsets val navigationOnly: WindowInsets
@Composable @Composable
get() = WindowInsets.navigationBars get() = WindowInsets.navigationBars
+1
View File
@@ -2,6 +2,7 @@
<string name="app_name">ReSearch</string> <string name="app_name">ReSearch</string>
<string name="settings_title">Настройки</string> <string name="settings_title">Настройки</string>
<string name="action_back">Назад</string> <string name="action_back">Назад</string>
<string name="version_format">v%1$s</string>
<string name="version_unknown">неизвестно</string> <string name="version_unknown">неизвестно</string>
<string name="button_cancel_download">Отмена</string> <string name="button_cancel_download">Отмена</string>
<string name="action_ok">ОК</string> <string name="action_ok">ОК</string>
+1
View File
@@ -2,6 +2,7 @@
<string name="app_name">ReSearch</string> <string name="app_name">ReSearch</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="action_back">Back</string> <string name="action_back">Back</string>
<string name="version_format">v%1$s</string>
<string name="version_unknown">unknown</string> <string name="version_unknown">unknown</string>
<string name="button_cancel_download">Cancel</string> <string name="button_cancel_download">Cancel</string>
<string name="action_ok">OK</string> <string name="action_ok">OK</string>