26 Commits
Author SHA1 Message Date
OneWay 4e75c7d865 Release 1.7.0
- Added search history
- The search bar can be placed at the top or bottom of the screen
- Fixed headwords with stress marks displaying truncated in search results
- Improved download, indexing and navigation stability
- The About screen opens faster
- Updated build tooling and code cleanup
2026-10-07 01:33:27 +08:00
OneWay 5a0426c864 fix(main): populate the search field and results on history click in wide mode
HistoryEntryClicked in wide mode only called SearchAndLoadArticle,
which never updates searchQuery, so the field stayed empty and the
results pager took the query.isBlank() branch with PagingData.empty().
Now dispatch onSearchQueryChanged first, then SearchAndLoadArticle.
2026-10-07 00:43:15 +08:00
OneWay 5b6bb08248 fix(article): align the back arrow with the title in compact mode
In the two-pane compact title bar the arrow sat slightly above
the first title line; offset it down to optically center it.
2026-10-07 00:43:15 +08:00
OneWay cea46c0955 fix(parser): keep the full headword in the display text
The display form was truncated at the first formatting tag, so headwords
with a mid-word stress mark displayed as their prefix only. Build the
display from the full text with tags stripped and render stress marks as
a combining acute accent. Bump the index version so existing indexes
rebuild.
2026-10-06 12:23:50 +08:00
OneWay 37127b7bc9 fix(about): correct the version display
Drop the v prefix so the version shows as a plain version name, and
align it by baseline with the app name instead of the bottom edge of
the headline line box.
2026-10-06 11:10:19 +08:00
OneWay 84fcd77f02 feat(settings): split pasted dictionary URLs into separate fields
Pasting several URLs separated by spaces, newlines or commas into one
field now distributes them across separate fields when every token is a
valid source template. Previously the joined text passed validation as a
single URL and failed at download time.
2026-10-06 11:08:37 +08:00
OneWay 3d83d04cea 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 4b627b4d05 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 5c655e769c 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 the scan finished. 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 0c4ab6dad5 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 87dccb4993 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 9de47c7f0f 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 9b6a34b607 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 d2c9dd7701 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 f227546111 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 cec914ed00 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 73384bc0eb 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 c4e4664542 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 59dfb53756 fix(ui): align back arrows with the search bar leading icon
Use one shared back button with a matching icon size, touch target
and start padding on the settings and about screens.
2026-10-04 12:18:00 +00:00
OneWay e423bd6c58 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 0bae625c01 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 344852a871 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 6146c0ebfe 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 1202662afe 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 c90f658bfe 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 cd589b30a4 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
39 changed files with 233 additions and 218 deletions

No files matched your search

+9 -4
View File
@@ -1,6 +1,6 @@
<h1 align="center">ReSearch</h1>
<h4 align="center">A fast Android dictionary reader for DSL-format dictionaries.</h4>
<h4 align="center">An Android dictionary reader for DSL-format dictionaries.</h4>
<p align="center">
<a href="#screenshots">Screenshots</a> &bull;
@@ -15,21 +15,26 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
## Screenshots
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="160" alt="ReSearch search screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/1.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="160" alt="ReSearch article screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/2.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="160" alt="ReSearch settings screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/1.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="160" alt="ReSearch search results screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/2.png)
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="160" alt="ReSearch about screen">](fastlane/metadata/android/en-US/images/phoneScreenshots/3.png)
## Features
- Fast headword search across multiple DSL dictionaries.
- Search history in the empty search state: recent queries with per-entry removal and clear-all.
- Search history in a bottom sheet: recent queries with per-entry removal and clear-all.
- Look up selected text from other apps via the selection menu.
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
- Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases.
- Multiple dictionary source URLs can be added in one list.
- Automatic extraction, DictZip compression, and indexing.
- Faithful article rendering with DSL indentation, margins, and named colors.
- Offline search after dictionaries are indexed.
- Random-access article loading from DictZip dictionaries.
- Background processing with progress shown in Settings and notifications; downloads are cancelled with a button on the progress notification.
- Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe.
- Search bar placement at the top or bottom of the screen.
- Adaptive two-pane layout for tablets and large screens.
- Material 3 interface with dynamic color, light, dark, and system themes.
- English and Russian localization.
- About screen with open source license information.
+2 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 9
versionName = "1.6.0"
versionCode = 10
versionName = "1.7.0"
vectorDrawables {
useSupportLibrary = true
@@ -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,12 +69,8 @@ class MainActivity : ComponentActivity() {
app.dictionaryImportManager,
app.dictionaryProgressStateHolder,
versionName,
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(
isThemeExpanded = false,
isLanguageExpanded = false,
isSearchBarExpanded = false,
isDictionariesExpanded = true,
),
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(),
app.initialLaunch.value?.searchBarPosition ?: SearchBarPosition.TOP,
)
}
}
@@ -147,7 +144,7 @@ class MainActivity : ComponentActivity() {
val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)
?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY)
text?.toString()?.let {
searchViewModel.onAction(SearchAction.HandleExternalSearch(it))
searchViewModel.onAction(SearchAction.QueryChanged(it))
}
}
}
@@ -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,20 +147,16 @@ class ReSearchApplication : Application() {
}
private suspend fun loadInitialLaunch(): InitialLaunch {
val defaultExpansions = SectionExpansionStates(
isThemeExpanded = false,
isLanguageExpanded = false,
isSearchBarExpanded = 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()),
)
}
@@ -1,13 +1,17 @@
package com.example.research.common.ui
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.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
fun Modifier.appTestTag(tag: String): Modifier = this
.testTag(tag)
@@ -24,3 +28,14 @@ fun Modifier.clickableWithHaptics(
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")
return@withContext null
}
waitForDeletion(targetFile)
awaitGone(targetFile::exists)
} else {
return@withContext Uri.fromFile(targetFile)
}
@@ -104,7 +104,7 @@ class FileStorageManager {
delay(CREATE_RETRY_DELAY.milliseconds)
if (targetFile.exists()) {
targetFile.delete()
waitForDeletion(targetFile)
awaitGone(targetFile::exists)
}
created = targetFile.createNewFile()
createAttempt++
@@ -130,7 +130,7 @@ class FileStorageManager {
for (attempt in 1..maxRetries) {
try {
if (file.delete()) {
waitForFileDeletion(file)
awaitGone(file::exists)
if (!file.exists()) {
return@withContext true
}
@@ -156,17 +156,9 @@ class FileStorageManager {
}
// FUSE-backed storage keeps a just-deleted file visible to stat/open for a short window
private suspend fun waitForDeletion(file: File) {
private suspend fun awaitGone(exists: () -> Boolean) {
var waitCount = 0
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) {
while (exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
delay(DELETE_WAIT_INTERVAL.milliseconds)
waitCount++
}
@@ -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
}
}
@@ -60,7 +60,10 @@ 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("[']")
if (firstOpen < 0) return this
@@ -80,7 +83,7 @@ fun String.removeAccentTagsForIndexing(): String {
builder = StringBuilder(length)
}
builder.append(this, appendStart, index)
builder.append(this, index + 3, scan)
appendInner(builder, index + 3, scan)
index = scan + 4
appendStart = index
continue
@@ -94,7 +97,7 @@ fun String.removeAccentTagsForIndexing(): String {
}?.toString() ?: this
}
private fun String.startsWithAt(start: Int, literal: String): Boolean {
internal 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
@@ -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,10 +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 isSearchBarExpanded: 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) {
@@ -65,14 +67,14 @@ class PreferencesManager(private val context: Context) {
}
}
val searchBarPosition: Flow<String> = context.dataStore.data
val searchBarPosition: Flow<SearchBarPosition> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.SEARCH_BAR_POSITION] ?: "TOP"
SearchBarPosition.fromStorage(preferences[PreferencesKeys.SEARCH_BAR_POSITION])
}
suspend fun saveSearchBarPosition(position: String) {
suspend fun saveSearchBarPosition(position: SearchBarPosition) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.SEARCH_BAR_POSITION] = position
preferences[PreferencesKeys.SEARCH_BAR_POSITION] = position.name
}
}
@@ -226,9 +228,9 @@ class PreferencesManager(private val context: Context) {
}
val searchHistory: Flow<List<String>> = context.dataStore.data
.map { preferences ->
decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
}
.map { preferences -> preferences[PreferencesKeys.SEARCH_HISTORY] }
.distinctUntilChanged()
.map(::decodeSearchHistory)
suspend fun addSearchHistoryEntry(query: String) {
context.dataStore.edit { preferences ->
@@ -1,5 +1,8 @@
package com.example.research.data.parser
import com.example.research.core.util.rewriteAccentTags
import com.example.research.core.util.startsWithAt
object DslHeadwordParser {
private val FORMATTING_LITERAL_TAGS = arrayOf(
@@ -117,12 +120,12 @@ object DslHeadwordParser {
}
private fun reconstructDisplayPrefix(value: String): String {
val reconstructed = removeCurlyBraceDelimiters(value)
val formattingTagIndex = firstFormattingTagIndex(reconstructed)
return if (formattingTagIndex >= 0) {
reconstructed.substring(0, formattingTagIndex).trim()
} else {
reconstructed.trim()
return removeFormattingTags(removeCurlyBraceDelimiters(value).applyAccentMarks()).trim()
}
private fun String.applyAccentMarks(): String = rewriteAccentTags { builder, from, to ->
if (to > from) {
builder.append(this[from]).append('\u0301').append(this, from + 1, to)
}
}
@@ -145,21 +148,6 @@ 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? {
var index = 0
while (index < value.length) {
@@ -346,14 +334,6 @@ object DslHeadwordParser {
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 {
return this == ' ' ||
this == '\t' ||
@@ -24,7 +24,7 @@ class DslIndexer(context: Context? = null) {
companion object {
private const val TAG = "DslIndexer"
const val INDEX_VERSION = 6
const val INDEX_VERSION = 7
const val BUFFER_SIZE = 1048576
const val BATCH_BUFFER_SIZE = 524288
private const val INDEX_SORT_PARALLELISM = 1
@@ -29,7 +29,6 @@ sealed interface SearchAction {
data class ArticleClicked(val entry: IndexEntry) : SearchAction
data object ClearArticle : SearchAction
data class SearchAndLoadArticle(val word: String) : SearchAction
data class HandleExternalSearch(val text: String) : SearchAction
}
class SearchViewModel(
@@ -142,7 +141,6 @@ class SearchViewModel(
}
SearchAction.ClearArticle -> clearArticle()
is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word)
is SearchAction.HandleExternalSearch -> handleExternalSearch(action.text)
}
}
@@ -186,6 +184,7 @@ class SearchViewModel(
private fun recordSearchHistory(query: String) {
val trimmed = query.trim()
if (trimmed.isBlank()) return
if (searchHistory.value.firstOrNull() == trimmed) return
viewModelScope.launch {
preferencesManager.addSearchHistoryEntry(trimmed)
}
@@ -322,10 +321,6 @@ class SearchViewModel(
}
}
fun handleExternalSearch(text: String) {
onSearchQueryChanged(text)
}
companion object {
private const val TAG = "SearchViewModel"
@@ -156,13 +156,14 @@ private fun AboutHeader(versionName: String) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.alignByBaseline()
)
Text(
text = stringResource(R.string.version_format, versionName),
text = versionName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 2.dp)
modifier = Modifier.alignByBaseline()
)
}
@@ -296,7 +296,7 @@ fun ArticleTitleBar(
val titleLineHeight = with(LocalDensity.current) {
MaterialTheme.typography.dictionaryTitleLarge.lineHeight.toDp()
}
Modifier.offset(y = (titleLineHeight - 48.dp) / 2)
Modifier.offset(y = (titleLineHeight - 48.dp) / 2 + 3.dp)
} else {
Modifier
}
@@ -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.ArticleTitleBar
import com.example.research.ui.article.ArticleState
import com.example.research.ui.main.components.MainTopBar
import com.example.research.ui.theme.AppWindowInsets
import com.example.research.ui.theme.Spacing
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -55,17 +55,13 @@ fun AdaptiveMainScreen(
val listEndPadding = if (isTwoPane) 8.dp else 16.dp
val searchBar: @Composable () -> Unit = {
MainTopBar(
MainSearchBar(
searchState = searchState,
enabled = canSearch,
canSearch = 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
onAction = onAction,
onNavigateToSettings = onNavigateToSettings,
)
}
@@ -74,7 +70,7 @@ fun AdaptiveMainScreen(
.fillMaxSize()
.then(
if (isSearchBarAtBottom) {
Modifier.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.ime))
Modifier.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
} else {
Modifier
}
@@ -1,14 +1,9 @@
package com.example.research.ui.main
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
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.padding
import androidx.compose.foundation.layout.windowInsetsPadding
@@ -27,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
@@ -48,11 +44,7 @@ fun MainRoute(
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
if (!settingsState.hasLoadedPreferences) {
Box(modifier = Modifier.fillMaxSize())
return
}
val isSearchBarAtBottom = settingsState.searchBarPosition == "BOTTOM"
val isSearchBarAtBottom = settingsState.searchBarPosition == SearchBarPosition.BOTTOM
val scrollPosition = remember(searchViewModel) {
searchViewModel.scrollPosition.value
@@ -101,6 +93,7 @@ fun MainRoute(
searchViewModel.saveScrollPosition(action.index, action.offset)
is MainAction.HistoryEntryClicked ->
if (isWide) {
searchViewModel.onSearchQueryChanged(action.query)
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(action.query))
} else {
searchViewModel.onSearchQueryChanged(action.query)
@@ -157,20 +150,14 @@ fun MainRoute(
}
@Composable
internal fun MainScreen(
internal fun MainSearchBar(
searchState: TextFieldState,
canSearch: Boolean,
isSearchBarAtBottom: Boolean,
results: LazyPagingItems<IndexEntry>,
errorMessage: String?,
showDictionaryName: Boolean,
searchHistory: List<String>,
scrollPosition: Pair<Int, Int>,
onAction: (MainAction) -> Unit,
onArticleClick: (IndexEntry) -> Unit,
onNavigateToSettings: () -> Unit
onNavigateToSettings: () -> Unit,
) {
val searchBar: @Composable () -> Unit = {
MainTopBar(
searchState = searchState,
enabled = canSearch,
@@ -185,11 +172,36 @@ internal fun MainScreen(
)
}
@Composable
internal fun MainScreen(
searchState: TextFieldState,
canSearch: Boolean,
isSearchBarAtBottom: Boolean,
results: LazyPagingItems<IndexEntry>,
errorMessage: String?,
showDictionaryName: Boolean,
searchHistory: List<String>,
scrollPosition: Pair<Int, Int>,
onAction: (MainAction) -> Unit,
onArticleClick: (IndexEntry) -> Unit,
onNavigateToSettings: () -> Unit
) {
val searchBar: @Composable () -> Unit = {
MainSearchBar(
searchState = searchState,
canSearch = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom,
searchHistory = searchHistory,
onAction = onAction,
onNavigateToSettings = onNavigateToSettings,
)
}
if (isSearchBarAtBottom) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.ime))
.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
) {
SearchResultsPane(
searchState = searchState,
@@ -1,18 +1,14 @@
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.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
@@ -33,7 +29,6 @@ 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
@@ -54,14 +49,15 @@ 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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import 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 kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
@@ -103,10 +99,6 @@ fun MainTopBar(
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
LaunchedEffect(searchHistory.isEmpty()) {
if (searchHistory.isEmpty()) showHistorySheet = false
}
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
@@ -125,9 +117,7 @@ fun MainTopBar(
)
.then(
if (isSearchBarAtBottom) {
Modifier.windowInsetsPadding(
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
)
Modifier.windowInsetsPadding(AppWindowInsets.bottomNavigation)
} else {
Modifier
}
@@ -179,7 +169,7 @@ fun MainTopBar(
}
}
if (showHistorySheet) {
if (showHistorySheet && searchHistory.isNotEmpty()) {
SearchHistorySheet(
history = searchHistory,
isSearchBarAtBottom = isSearchBarAtBottom,
@@ -241,13 +231,8 @@ private fun SearchField(
modifier = Modifier
.offset(x = 4.dp)
.size(28.dp)
.testTag("history_button")
.semantics { testTagsAsResourceId = true }
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 20.dp),
enabled = historyEnabled
) { onShowHistory() }
.appTestTag("history_button")
.iconClickable(enabled = historyEnabled, onClick = onShowHistory)
)
},
trailingIcon = if (searchState.text.isNotEmpty()) {
@@ -307,17 +292,16 @@ private fun SearchHistorySheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val density = LocalDensity.current
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
var frozenHeight by remember { mutableStateOf<Dp?>(null) }
if (frozenHeight == null) {
frozenHeight = if (isSearchBarAtBottom) {
screenHeight - HistorySheetChrome - with(density) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
val navigationBarsBottom = WindowInsets.navigationBars.getBottom(density)
val maxContentHeight = remember {
with(density) {
if (isSearchBarAtBottom) {
screenHeight - HistorySheetChrome - navigationBarsBottom.toDp()
} else {
screenHeight - with(density) { topBarBottomPx.toDp() }
screenHeight - topBarBottomPx.toDp()
}
}
}
val maxContentHeight = frozenHeight!!
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -2,7 +2,6 @@ package com.example.research.ui.main.components
import androidx.compose.foundation.background
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.PaddingValues
@@ -19,20 +18,17 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.style.TextOverflow
import androidx.compose.ui.unit.dp
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.ui.theme.Spacing
@@ -48,8 +44,7 @@ fun SearchHistoryList(
) {
LazyColumn(
modifier = modifier
.testTag("search_history")
.semantics { testTagsAsResourceId = true },
.appTestTag("search_history"),
state = listState,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(4.dp)
@@ -76,8 +71,7 @@ fun SearchHistoryList(
.padding(end = 18.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.testTag("clear_history")
.semantics { testTagsAsResourceId = true }
.appTestTag("clear_history")
.clickable { onClearAll() }
.padding(horizontal = 12.dp, vertical = 6.dp)
)
@@ -106,8 +100,7 @@ private fun SearchHistoryItem(
Row(
modifier = Modifier
.fillMaxWidth()
.testTag("search_history_entry")
.semantics { testTagsAsResourceId = true }
.appTestTag("search_history_entry")
.clickable { onEntryClick(entry) }
.padding(vertical = Spacing.listItemVertical),
verticalAlignment = Alignment.CenterVertically
@@ -136,12 +129,8 @@ private fun SearchHistoryItem(
contentDescription = stringResource(R.string.remove_search_history_entry),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.testTag("remove_history_entry")
.semantics { testTagsAsResourceId = true }
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 20.dp)
) { onEntryRemove(entry) }
.appTestTag("remove_history_entry")
.iconClickable { onEntryRemove(entry) }
.size(20.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,7 +14,7 @@ import com.example.research.feature.download.model.DownloadState
data class SettingsUiState(
val theme: AppTheme = AppTheme.SYSTEM,
val language: String = "system",
val searchBarPosition: String = "TOP",
val searchBarPosition: SearchBarPosition = SearchBarPosition.TOP,
val isThemeExpanded: Boolean = false,
val isLanguageExpanded: Boolean = false,
val isSearchBarExpanded: Boolean = false,
@@ -25,7 +26,6 @@ data class SettingsUiState(
val importState: ImportState = ImportState.Idle,
val dictionarySources: List<DictionarySource> = emptyList(),
val hasCompletedStartupScan: Boolean = false,
val hasLoadedPreferences: Boolean = false,
val pipelineIdle: Boolean = true,
val appVersion: String = "1.0",
)
@@ -33,7 +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: 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,7 +47,7 @@ private data class DictionaryStateInputs(
private data class OperationStateInputs(
val downloadState: DownloadState,
val language: String,
val searchBarPosition: String,
val searchBarPosition: SearchBarPosition,
val importState: ImportState,
val dictionaryStatus: DictionaryStatus,
val hasCompletedStartupScan: Boolean,
@@ -54,7 +55,6 @@ private data class OperationStateInputs(
val isLanguageExpanded: Boolean,
val isSearchBarExpanded: Boolean,
val isDictionariesExpanded: Boolean,
val hasLoadedPreferences: Boolean,
)
class SettingsViewModel(
@@ -67,6 +67,7 @@ class SettingsViewModel(
progressStateHolder: DictionaryProgressStateHolder,
appVersion: String,
initialSectionExpansion: SectionExpansionStates,
initialSearchBarPosition: SearchBarPosition,
) : AndroidViewModel(application) {
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
@@ -79,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()
@@ -92,15 +95,14 @@ class SettingsViewModel(
private val dictionaryStatus = MutableStateFlow<DictionaryStatus>(DictionaryStatus.Unknown)
private val language = MutableStateFlow("system")
private val searchBarPosition = MutableStateFlow("TOP")
private val hasCompletedStartupScan = MutableStateFlow(false)
private val hasLoadedPreferences = MutableStateFlow(false)
private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>()
private var isDownloadInProgress = false
private var cancelRefreshPending = false
private var errorStateHandled = false
private var successStateHandled = false
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
init {
@@ -156,7 +158,7 @@ class SettingsViewModel(
val operationStateInputs = combine(
downloadManager.downloadState,
language,
searchBarPosition,
preferencesManager.searchBarPosition,
dictionaryImportManager.importState,
dictionaryStatus,
hasCompletedStartupScan,
@@ -164,13 +166,12 @@ class SettingsViewModel(
preferencesManager.isLanguageExpanded,
preferencesManager.isSearchBarExpanded,
preferencesManager.isDictionariesExpanded,
hasLoadedPreferences,
) { params: Array<Any> ->
@Suppress("UNCHECKED_CAST")
OperationStateInputs(
downloadState = params[0] as DownloadState,
language = params[1] as String,
searchBarPosition = params[2] as String,
searchBarPosition = params[2] as SearchBarPosition,
importState = params[3] as ImportState,
dictionaryStatus = params[4] as DictionaryStatus,
hasCompletedStartupScan = params[5] as Boolean,
@@ -178,7 +179,6 @@ class SettingsViewModel(
isLanguageExpanded = params[7] as Boolean,
isSearchBarExpanded = params[8] as Boolean,
isDictionariesExpanded = params[9] as Boolean,
hasLoadedPreferences = params[10] as Boolean,
)
}
@@ -189,14 +189,18 @@ class SettingsViewModel(
isDownloadInProgress = true
cancelRefreshPending = false
errorStateHandled = false
successStateHandled = false
}
is DownloadState.Success -> {
pendingSourceUrls.clear()
isDownloadInProgress = false
if (!successStateHandled) {
successStateHandled = true
dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
}
}
is DownloadState.Error -> {
isDownloadInProgress = false
if (!errorStateHandled) {
@@ -257,7 +261,6 @@ class SettingsViewModel(
importState = operationState.importState,
dictionaryStatus = operationState.dictionaryStatus,
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
hasLoadedPreferences = operationState.hasLoadedPreferences,
pipelineIdle = !isIndexing &&
!operationState.downloadState.isActive &&
!operationState.importState.isActive,
@@ -298,12 +301,6 @@ class SettingsViewModel(
} catch (e: Exception) {
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
if (scanCompleted) {
preferencesManager.setHadNoDictionaries(
@@ -391,10 +388,9 @@ class SettingsViewModel(
}
}
private fun saveSearchBarPosition(position: String) {
private fun saveSearchBarPosition(position: SearchBarPosition) {
viewModelScope.launch {
preferencesManager.saveSearchBarPosition(position)
searchBarPosition.value = position
}
}
@@ -66,6 +66,15 @@ 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 {
val text = toString()
val filtered = text
@@ -92,6 +101,17 @@ fun DictionaryUrlInputContent(
val scrollState = rememberScrollState()
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(
empty = stringResource(R.string.dictionary_source_url_empty),
duplicateInForm = stringResource(R.string.dictionary_source_duplicate_in_form),
@@ -113,7 +133,7 @@ fun DictionaryUrlInputContent(
val inputUrlPrefixes = remember {
derivedStateOf {
urls.associate { urlState ->
val urlString = urlState.text.trim().toString()
val urlString = urlState.trimmedText()
urlString to if (urlString.isNotEmpty()) {
DictionarySource.extractPrefix(urlString)
} else null
@@ -138,7 +158,7 @@ fun DictionaryUrlInputContent(
urls.forEachIndexed { index, urlState ->
val isLastItem = index == urls.lastIndex
val urlString = urlState.text.trim().toString()
val urlString = urlState.trimmedText()
val cachedPrefix = inputUrlPrefixes.value[urlString]
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
validateDictionaryUrl(
@@ -146,7 +166,7 @@ fun DictionaryUrlInputContent(
url = urlString,
existingSources = existingSources,
existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.text.trim().toString() },
urlsInForm = urls.map { it.trimmedText() },
urlPrefix = cachedPrefix,
errorMessages = errorMessages
)
@@ -229,7 +249,7 @@ fun DictionaryUrlInputContent(
val seenUrls = mutableSetOf<String>()
urls.forEachIndexed { index, urlState ->
val urlString = urlState.text.trim().toString()
val urlString = urlState.trimmedText()
if (urlString.isBlank()) {
if (urls.size == 1) {
validationErrors[index] = errorMessages.empty
@@ -267,9 +287,9 @@ fun DictionaryUrlInputContent(
.weight(1f)
.testTag("add_confirm_button")
.semantics { testTagsAsResourceId = true },
enabled = urls.any { it.text.trim().toString().isNotBlank() } &&
enabled = urls.any { it.trimmedText().isNotBlank() } &&
urls.none { urlState ->
val urlString = urlState.text.trim().toString()
val urlString = urlState.trimmedText()
if (urlString.isEmpty()) {
false
} else {
@@ -279,7 +299,7 @@ fun DictionaryUrlInputContent(
url = urlString,
existingSources = existingSources,
existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.text.trim().toString() },
urlsInForm = urls.map { it.trimmedText() },
urlPrefix = urlPrefix,
errorMessages = errorMessages
)
@@ -293,3 +313,5 @@ fun DictionaryUrlInputContent(
Spacer(modifier = Modifier.height(8.dp))
}
}
private fun TextFieldState.trimmedText(): String = text.trim().toString()
@@ -10,30 +10,29 @@ 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: String,
onPositionChanged: (String) -> Unit,
currentPosition: SearchBarPosition,
onPositionChanged: (SearchBarPosition) -> Unit,
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(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
positions.forEach { (code, label) ->
val isSelected = currentPosition == code
SearchBarPosition.entries.forEach { position ->
val isSelected = currentPosition == position
OutlinedChoiceButton(
text = label,
onClick = { onPositionChanged(code) },
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_$code"),
.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
-1
View File
@@ -2,7 +2,6 @@
<string name="app_name">ReSearch</string>
<string name="settings_title">Настройки</string>
<string name="action_back">Назад</string>
<string name="version_format">v%1$s</string>
<string name="version_unknown">неизвестно</string>
<string name="button_cancel_download">Отмена</string>
<string name="action_ok">ОК</string>
-1
View File
@@ -2,7 +2,6 @@
<string name="app_name">ReSearch</string>
<string name="settings_title">Settings</string>
<string name="action_back">Back</string>
<string name="version_format">v%1$s</string>
<string name="version_unknown">unknown</string>
<string name="button_cancel_download">Cancel</string>
<string name="action_ok">OK</string>
@@ -1 +1 @@
- Initial F-Droid release
- Initial release
@@ -0,0 +1,6 @@
- Added search history
- The search bar can be placed at the top or bottom of the screen
- Fixed headwords with stress marks displaying truncated in search results
- Improved download, indexing and navigation stability
- The About screen opens faster
- Updated build tooling and code cleanup
@@ -1,4 +1,6 @@
- The app starts faster
- Faster dictionary indexing
- Improved app stability
- Smooth screen transition animations
- Articles render DSL line breaks, indentation and named colors correctly
- Tabs and extra spaces inside articles no longer break the layout
@@ -8,9 +10,7 @@
- Download progress notifications are more reliable
- Search results clear instantly when the query is erased
- Leftover files from cancelled downloads are now removed automatically
- Improved app stability
- Faster dictionary indexing
- Updated build tooling and documentation
- Long link-heavy articles no longer freeze while scrolling
- Large search result lists render faster
- Dark article colors are readable in the dark theme
- Updated build tooling and documentation
@@ -1,4 +1,4 @@
ReSearch is a fast Android dictionary reader for DSL-format dictionaries. Download, import, and search multiple dictionaries with instant article retrieval via DictZip random-access compression.
ReSearch is an Android dictionary reader for DSL-format dictionaries. Download, import, and search multiple dictionaries with instant article retrieval via DictZip random-access compression.
Features:
@@ -8,7 +8,7 @@ Features:
- Import .dsl and .dsl.dz files from device storage
- Automatic indexing with binary index files for instant search
- Multi-charset support — UTF-8, UTF-16 LE/BE auto-detection
- Adaptive three-pane layout for tablets and large screens
- Adaptive two-pane layout for tablets and large screens
- Material 3 UI with dynamic color, plus light, dark, and system-follow themes
- English and Russian localization
- No tracking, no ads, fully open source
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

@@ -1 +1 @@
- Первый релиз для F-Droid
- Первый релиз
@@ -0,0 +1,6 @@
- Добавлена история поиска
- Поисковую строку можно расположить сверху или снизу экрана
- Исправлено усечение заголовков со знаками ударения в результатах поиска
- Повышена стабильность загрузки, индексации и навигации
- Экран «О приложении» открывается быстрее
- Обновлён инструментарий сборки, проведена чистка кода
@@ -1,4 +1,6 @@
- Приложение запускается быстрее
- Ускорена индексация словарей
- Повышена стабильность приложения
- Плавные анимации переходов между экранами
- Статьи корректно отображают переносы строк, отступы и цвета из DSL-разметки
- Табуляции и лишние пробелы в статьях больше не ломают вёрстку
@@ -8,9 +10,7 @@
- Уведомления о прогрессе загрузки стали надёжнее
- Результаты поиска очищаются мгновенно при стирании запроса
- Остаточные файлы после отменённых загрузок удаляются автоматически
- Повышена стабильность приложения
- Ускорена индексация словарей
- Обновлены тулинг сборки и документация
- Длинные статьи со ссылками больше не подвисают при прокрутке
- Большие списки результатов поиска отображаются быстрее
- Тёмные цвета статей читаемы в тёмной теме
- Обновлены инструменты сборки и документация
@@ -1,4 +1,4 @@
ReSearch — быстрое приложение для чтения словарей в формате DSL. Скачивайте, импортируйте и ищите в нескольких словарях с мгновенным получением статей благодаря сжатию DictZip.
ReSearch — приложение для чтения словарей в формате DSL. Скачивайте, импортируйте и ищите в нескольких словарях с мгновенным получением статей благодаря сжатию DictZip.
Возможности:
@@ -8,7 +8,7 @@ ReSearch — быстрое приложение для чтения слова
- Импорт файлов .dsl и .dsl.dz из хранилища устройства
- Автоматическая индексация с бинарными индексными файлами для мгновенного поиска
- Поддержка нескольких кодировок — автоопределение UTF-8, UTF-16 LE/BE
- Адаптивный трёхпанельный интерфейс для планшетов и больших экранов
- Адаптивный двухпанельный интерфейс для планшетов и больших экранов
- Material 3 с динамическими цветами, светлая, тёмная и системная темы
- Локализация на английский и русский языки
- Без трекеров, без рекламы, полностью открытый исходный код
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB