10 Commits
Author SHA1 Message Date
OneWay cf64986371 fix: clean up orphaned and partial files after cancelled downloads
Treat inactive download contexts as cancellation, delete the partial file, and
rethrow the cancellation so the pipeline does not retain an incomplete payload.
Also remove orphaned .dsl/.dz files left behind by cancelled downloads.
Run the partial/orphan deletions on NonCancellable: the cleanup is invoked from
already-cancelled coroutines, where withContext(Dispatchers.IO) aborts before
executing the block and silently leaves the files behind.
2026-09-14 07:50:30 +08:00
OneWay 4d577a6b13 fix: restore boundary progress publishes and animate the bar at constant velocity 2026-09-11 16:37:23 +03:00
OneWay d15781a176 chore: drop the unused indexing_label string 2026-09-11 16:28:23 +03:00
OneWay ee40748b00 refactor: slim IndexingProgress to a producer-supplied [0,1] progress
perFileProgress was never populated and its progress branch was
unreachable; currentFile, currentIndex, currentFileProgress and label
were write-only (no consumer read them); progressPercent had no
callers; the -1f aggregate sentinel is gone along with the fallback
branches that only fired when the tracker was absent.
2026-09-11 16:28:23 +03:00
OneWay 380e4ca916 fix: latch terminal notification state and post pipeline progress on one thread
A late ImportState Success snapshot collected after the success
notification overwrote it with an ongoing progress record. Post
progress on Main behind a volatile terminal latch; notifications go
through NotificationPort, the pipeline lives in the coordinator, and
the service finish is injected.
2026-09-06 15:32:35 +08:00
OneWay 47b1197a92 fix: defer import errors until the copy loop finishes
Errors and skips are collected while the copy loop runs and reported
once it ends. The final import state is exhaustive - success, already
added, invalid name, or nothing supported found - so every import run
reaches a terminal state and the pipeline notification always resolves
instead of lingering as an indeterminate progress bar.
2026-09-05 21:55:01 +08:00
OneWay c1563b8172 fix: tie back arrow to pipeline idle state
Computed from operation states in SettingsViewModel instead of a fixed
delay; cancel and error release the arrow immediately.
2026-09-05 05:55:56 +08:00
OneWay 4a00d8adff fix: key search result rows by dictionary, offset and word
Paging result rows had no keys and were matched by position: appended
pages and query diffs could not reuse row state. Keys derived from
dictionaryPath+offset+word are stable per row, and the paging source
collapses duplicate entries for one dictionary card: case and diacritic
alias variants of a headword share the normalized word, so entries are
deduplicated by dictionaryPath+word+offset before emitting.
2026-09-05 03:00:44 +08:00
OneWay 944d2e6634 perf(about): parse aboutlibraries.json off the main thread
Bump AboutLibraries plugin to 15.0.4 -> 15.2.0; parse
aboutlibraries.json on Dispatchers.IO via produceState (header renders
immediately, rows fill in); drop animateContentSize from rows.
2026-09-05 00:37:41 +08:00
OneWay d7de09a152 fix: open Settings immediately on cold start when no dictionaries
Seed and synchronously read hadNoDictionaries before setContent so
Settings shows from the first frame until the startup scan confirms.
2026-09-04 19:09:19 +08:00
74 changed files with 617 additions and 1844 deletions

No files matched your search

-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 OneWay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+29 -20
View File
@@ -1,10 +1,11 @@
<h1 align="center">ReSearch</h1>
<h4 align="center">An Android dictionary reader for DSL-format dictionaries.</h4>
<h4 align="center">A fast Android dictionary reader for DSL-format dictionaries.</h4>
<p align="center">
<a href="#screenshots">Screenshots</a> &bull;
<a href="#features">Features</a> &bull;
<a href="#architecture">Architecture</a> &bull;
<a href="#dictionary-pipeline">Pipeline</a> &bull;
<a href="#build">Build</a>
</p>
@@ -15,30 +16,38 @@ 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 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)
[<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)
## Features
- Fast headword search across multiple DSL dictionaries.
- 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.
- Download dictionaries from configured URLs.
- 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.
- Background processing with progress shown in Settings and notifications.
- Dictionary management with enable, disable, update, and delete actions.
- Material 3 interface with dynamic color, light, dark, and system themes.
- English and Russian localization.
- About screen with open source license information.
## Architecture
ReSearch is an Android app written in Kotlin and Jetpack Compose.
```text
com.example.research/
├── common/ # shared progress, UI, file, archive, notification utilities
├── core/ # domain models, source validation, operation results
├── data/ # DictZip, DSL parsing, indexing, paging, repositories
├── feature/ # search, dictionary download, local import workflows
└── ui/ # Compose screens, article view, settings, navigation
```
State is exposed through Kotlin Flow and collected by Compose screens. Long-running work uses coroutines and foreground services where Android requires visible progress.
## Dictionary Pipeline
ReSearch supports both downloaded and locally imported dictionaries.
@@ -85,16 +94,16 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack
- Kotlin 2.4.20
- Jetpack Compose (BOM 2026.09.00)
- Kotlin 2.4.10
- Jetpack Compose (BOM 2026.08.00)
- Material 3
- Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1
- Paging 3.5.1
- OkHttp 5.5.0
- OkHttp 5.4.0
- kotlinx.serialization 1.11.0
- AboutLibraries 15.2.0 metadata generation
- Android Gradle Plugin 9.4.1
- AboutLibraries 15.0.4 metadata generation
- Android Gradle Plugin 9.3.1
## Build
@@ -102,8 +111,8 @@ The main search screen remains usable as long as at least one indexed dictionary
./gradlew :app:assembleRelease
```
The release APK targets `arm64-v8a`.
The release APK targets `arm64-v8a`. English and Russian resources are packaged together.
## License
[MIT](LICENSE)
MIT
+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 = 10
versionName = "1.7.0"
versionCode = 8
versionName = "1.5.0"
vectorDrawables {
useSupportLibrary = true
@@ -26,12 +26,12 @@ 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
import com.example.research.ui.navigation.AppNavigation
import com.example.research.ui.settings.SettingsViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class MainActivity : ComponentActivity() {
companion object {
@@ -46,7 +46,7 @@ class MainActivity : ComponentActivity() {
val app = application as ReSearchApplication
viewModelFactory {
initializer {
SearchViewModel(application, app.localDictionaryRepository, app.preferencesManager, createSavedStateHandle())
SearchViewModel(application, app.localDictionaryRepository, createSavedStateHandle())
}
}
}
@@ -69,8 +69,6 @@ class MainActivity : ComponentActivity() {
app.dictionaryImportManager,
app.dictionaryProgressStateHolder,
versionName,
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(),
app.initialLaunch.value?.searchBarPosition ?: SearchBarPosition.TOP,
)
}
}
@@ -81,16 +79,18 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
// Load theme and language synchronously before setContent to prevent flashing
val app = application as ReSearchApplication
val initialTheme = loadInitialTheme(app)
val initialLanguage = loadInitialLanguage(app)
val initialHadNoDictionaries = loadInitialHadNoDictionaries(app)
handleIntent(intent)
setContent {
val initialLaunch by app.initialLaunch.collectAsStateWithLifecycle()
val launch = initialLaunch ?: return@setContent
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
// Use loaded values initially, then switch to state values when ready
val theme = settingsState.theme.takeIf { it != AppTheme.SYSTEM } ?: launch.theme
val language = settingsState.language.takeIf { it != "system" } ?: launch.language
val theme = settingsState.theme.takeIf { it != AppTheme.SYSTEM } ?: initialTheme
val language = settingsState.language.takeIf { it != "system" } ?: initialLanguage
val darkTheme = when (theme) {
AppTheme.LIGHT -> false
AppTheme.DARK -> true
@@ -106,7 +106,7 @@ class MainActivity : ComponentActivity() {
AppNavigation(
searchViewModel = searchViewModel,
settingsViewModel = settingsViewModel,
seedNoDictionaries = launch.seedNoDictionaries
seedNoDictionaries = initialHadNoDictionaries
)
}
}
@@ -129,6 +129,54 @@ class MainActivity : ComponentActivity() {
}
}
/**
* Load initial theme synchronously from DataStore to prevent theme flashing.
* This is called before setContent to ensure the correct theme is applied immediately.
* Uses runBlocking to synchronously read from DataStore.
*/
private fun loadInitialTheme(app: ReSearchApplication): AppTheme {
return try {
runBlocking {
val themeString = app.preferencesManager.themeMode.first()
try {
AppTheme.valueOf(themeString)
} catch (_: Exception) {
android.util.Log.w("MainActivity", "Invalid theme value: $themeString, using SYSTEM")
AppTheme.SYSTEM
}
}
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to load theme preference: ${e.message}")
AppTheme.SYSTEM
}
}
/**
* Load initial language synchronously from DataStore to prevent language flashing.
* This is called before setContent to ensure the correct language is applied immediately.
* Uses runBlocking to synchronously read from DataStore.
*/
private fun loadInitialLanguage(app: ReSearchApplication): String {
return try {
runBlocking {
app.preferencesManager.language.first()
}
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to load language preference: ${e.message}")
"system"
}
}
private fun loadInitialHadNoDictionaries(app: ReSearchApplication): Boolean {
return try {
runBlocking {
app.preferencesManager.hadNoDictionaries.first()
}
} catch (e: Exception) {
true
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
@@ -144,7 +192,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.QueryChanged(it))
searchViewModel.onAction(SearchAction.HandleExternalSearch(it))
}
}
}
@@ -3,10 +3,7 @@ package com.example.research
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
import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.repository.DictionaryRepository
@@ -15,26 +12,10 @@ import com.example.research.feature.import.DictionaryImportManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Protocol
/**
* Values a cold start needs before the first composed frame: the initial
* theme, locale, empty-library navigation seed, and settings expansion state.
*/
data class InitialLaunch(
val theme: AppTheme,
val language: String,
val searchBarPosition: SearchBarPosition,
val seedNoDictionaries: Boolean,
val sectionExpansion: SectionExpansionStates,
)
class ReSearchApplication : Application() {
lateinit var preferencesManager: PreferencesManager
@@ -62,9 +43,6 @@ class ReSearchApplication : Application() {
private set
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _initialLaunch = MutableStateFlow<InitialLaunch?>(null)
val initialLaunch: StateFlow<InitialLaunch?> = _initialLaunch.asStateFlow()
override fun onCreate() {
super.onCreate()
@@ -75,9 +53,6 @@ class ReSearchApplication : Application() {
applicationScope.launch(Dispatchers.IO) {
preferencesManager.sanitizeDictionarySources()
}
applicationScope.launch(Dispatchers.IO) {
_initialLaunch.value = loadInitialLaunch()
}
localDictionaryRepository = LocalDictionaryRepository(this, preferencesManager = preferencesManager)
okHttpClient = OkHttpClient.Builder()
@@ -146,20 +121,6 @@ class ReSearchApplication : Application() {
downloadManager.onTerminal = dictionaryPipelineCoordinator::onDownloadTerminal
}
private suspend fun loadInitialLaunch(): InitialLaunch {
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(SectionExpansionStates()),
)
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (::localDictionaryRepository.isInitialized) {
@@ -62,7 +62,7 @@ class DictionaryPipelineCoordinator(
pipelineJob?.cancel()
pipelineJob = scope.launch(launchDispatcher) {
val dir = File(dictionaryPath())
val filesBeforeReindex = dir.listFiles()?.map { it.name }?.toSet() ?: emptySet()
val filesBeforeReindex = dir?.listFiles()?.map { it.name }?.toSet() ?: emptySet()
try {
triggerReindexing(isImport)
if (isImport) {
@@ -85,7 +85,7 @@ class DictionaryPipelineCoordinator(
}
cleanupOrphanedFiles(dir)
throw e
} catch (_: Exception) {
} catch (e: Exception) {
ui.showError()
} finally {
finishService()
@@ -109,7 +109,7 @@ object DictionaryProgressModel {
return Snapshot(
progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_preparing_indexing_title,
titleRes = R.string.notification_indexing_title,
)
}
@@ -120,7 +120,7 @@ object DictionaryProgressModel {
} else {
DOWNLOAD_WEIGHT + EXTRACT_WEIGHT + idx * INDEX_WEIGHT
}
val pct = (p * 100f).toInt().coerceIn(0, 99)
val pct = (p * 100f).toInt().coerceIn(0, 100)
return Snapshot(
progress = p,
@@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class DictionaryProgressPresenter(
scope: CoroutineScope,
private val scope: CoroutineScope,
private val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?>,
private val notifications: NotificationPort,
mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
@@ -32,7 +32,6 @@ class DictionaryProgressPresenter(
fun beginFlow() {
terminalActive = false
notifications.cancelTerminalNotification()
}
override fun showImportSuccess() {
@@ -6,6 +6,5 @@ interface NotificationPort {
fun showDownloadSuccess()
fun showError()
fun cancelProgressNotification()
fun cancelTerminalNotification()
fun hasActiveProgressNotification(): Boolean
}
@@ -1,25 +0,0 @@
package com.example.research.common.ui
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.research.R
@Composable
fun AppBackButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
IconButton(onClick = onClick, modifier = modifier.size(48.dp)) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back),
contentDescription = stringResource(R.string.action_back),
modifier = Modifier.size(28.dp)
)
}
}
@@ -1,17 +1,13 @@
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)
@@ -28,14 +24,3 @@ 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
)
@@ -19,8 +19,6 @@ class FileStorageManager {
private const val DELETE_WAIT_INTERVAL = 50L
private const val MAX_DELETE_WAIT_COUNT = 10
private const val DEFAULT_MAX_RETRIES = 5
private const val CREATE_RETRY_DELAY = 100L
private const val MAX_CREATE_RETRIES = 3
private const val TAG = "FileStorageManager"
}
@@ -89,7 +87,6 @@ class FileStorageManager {
Log.e(TAG, "Could not delete existing file: $fileName")
return@withContext null
}
awaitGone(targetFile::exists)
} else {
return@withContext Uri.fromFile(targetFile)
}
@@ -98,18 +95,7 @@ class FileStorageManager {
if (tempBinFile.exists()) {
tempBinFile.delete()
}
var created = targetFile.createNewFile()
var createAttempt = 0
while (!created && createAttempt < MAX_CREATE_RETRIES) {
delay(CREATE_RETRY_DELAY.milliseconds)
if (targetFile.exists()) {
targetFile.delete()
awaitGone(targetFile::exists)
}
created = targetFile.createNewFile()
createAttempt++
}
if (created) {
if (targetFile.createNewFile()) {
Uri.fromFile(targetFile)
} else {
Log.e(TAG, "Failed to create file: $fileName")
@@ -130,7 +116,7 @@ class FileStorageManager {
for (attempt in 1..maxRetries) {
try {
if (file.delete()) {
awaitGone(file::exists)
waitForFileDeletion(file)
if (!file.exists()) {
return@withContext true
}
@@ -155,10 +141,9 @@ class FileStorageManager {
}
}
// 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 waitForFileDeletion(file: DocumentFile) {
var waitCount = 0
while (exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
delay(DELETE_WAIT_INTERVAL.milliseconds)
waitCount++
}
@@ -13,7 +13,6 @@ class NotificationHelper(private val context: Context) : NotificationPort {
companion object {
const val CHANNEL_ID = "download_progress_channel"
const val NOTIFICATION_ID = 1
const val TERMINAL_NOTIFICATION_ID = 2
}
private val notificationManager = NotificationManagerCompat.from(context)
private var lastNotificationKey: Int = 0
@@ -59,12 +58,13 @@ class NotificationHelper(private val context: Context) : NotificationPort {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
manager?.createNotificationChannel(channel)
}
private fun canShowNotification(): Boolean =
android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.POST_NOTIFICATIONS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
private fun canShowNotification(): Boolean {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) return true
return ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.POST_NOTIFICATIONS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun showProgress(percent: Int, titleRes: Int) {
showUnifiedProgressNotification(
title = context.getString(titleRes),
@@ -91,7 +91,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true)
.build()
safeNotifyTerminal(notification)
safeNotify(notification)
}
fun showErrorNotification() {
if (!canShowNotification()) return
@@ -103,17 +103,13 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true)
.build()
safeNotifyTerminal(notification)
safeNotify(notification)
}
fun cancelNotification() {
lastNotificationKey = 0
notificationManager.cancel(NOTIFICATION_ID)
}
override fun cancelTerminalNotification() {
notificationManager.cancel(TERMINAL_NOTIFICATION_ID)
}
override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0
fun showUnifiedProgressNotification(
@@ -165,14 +161,6 @@ class NotificationHelper(private val context: Context) : NotificationPort {
// Notification permission denied or security policy blocked
}
}
@SuppressLint("MissingPermission")
private fun safeNotifyTerminal(notification: Notification) {
try {
notificationManager.notify(TERMINAL_NOTIFICATION_ID, notification)
} catch (_: SecurityException) {
}
}
fun showImportSuccessNotification() {
if (!canShowNotification()) return
lastNotificationKey = 0
@@ -183,7 +171,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true)
.build()
safeNotifyTerminal(notification)
safeNotify(notification)
}
fun createForegroundNotification(): Notification {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -102,15 +102,12 @@ data class DictionarySource(
fun validateTemplate(template: String): ValidationResult {
val trimmed = template.trim()
val lowerTemplate = trimmed.lowercase()
return when {
trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY)
trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG)
!trimmed.startsWith("https://") ->
!trimmed.startsWith("http://") && !trimmed.startsWith("https://") ->
ValidationResult.Error(ValidationError.INVALID_PROTOCOL)
!lowerTemplate.endsWith(".gz") &&
!lowerTemplate.endsWith(".dsl") &&
!lowerTemplate.endsWith(".dsl.dz") ->
!trimmed.endsWith(".gz") && !trimmed.endsWith(".dsl") && !trimmed.endsWith(".dz") ->
ValidationResult.Error(ValidationError.INVALID_EXTENSION)
!isValidUrlStructure(trimmed) ->
ValidationResult.Error(ValidationError.INVALID_URL_STRUCTURE)
@@ -135,15 +132,8 @@ data class DictionarySource(
val parts = afterProtocol.split("/")
if (parts.size < 2) return false
val hostWithPort = parts[0]
if (hostWithPort.isEmpty()) return false
val colonIndex = hostWithPort.lastIndexOf(':')
if (colonIndex >= 0) {
val port = hostWithPort.substring(colonIndex + 1).toIntOrNull()
if (port == null || port !in 1..65535) return false
}
val domain = if (colonIndex >= 0) hostWithPort.substring(0, colonIndex) else hostWithPort
val domain = parts[0]
if (domain.isEmpty()) return false
if (domain != "localhost" && !domain.contains(".")) return false
val filename = parts.last()
@@ -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 =
rewriteAccentTags { builder, from, to -> builder.append(this, from, to) }
internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, Int) -> Unit): String {
fun String.removeAccentTagsForIndexing(): String {
val firstOpen = indexOf("[']")
if (firstOpen < 0) return this
@@ -83,7 +80,7 @@ internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, I
builder = StringBuilder(length)
}
builder.append(this, appendStart, index)
appendInner(builder, index + 3, scan)
builder.append(this, index + 3, scan)
index = scan + 4
appendStart = index
continue
@@ -97,7 +94,7 @@ internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, I
}?.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
for (i in literal.indices) {
if (this[start + i] != literal[i]) return false
@@ -9,41 +9,23 @@ 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
import java.io.File
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
data class SectionExpansionStates(
val isThemeExpanded: Boolean = false,
val isLanguageExpanded: Boolean = false,
val isSearchBarExpanded: Boolean = false,
val isDictionariesExpanded: Boolean = true,
)
class PreferencesManager(private val context: Context) {
private object PreferencesKeys {
val THEME_MODE = stringPreferencesKey("theme_mode")
val DICTIONARY_SOURCES = stringPreferencesKey("dictionary_sources")
val LANGUAGE = stringPreferencesKey("language")
val SEARCH_BAR_POSITION = stringPreferencesKey("search_bar_position")
val IS_THEME_EXPANDED = booleanPreferencesKey("is_theme_expanded")
val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded")
val IS_SEARCH_BAR_EXPANDED = booleanPreferencesKey("is_search_bar_expanded")
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
val DICTIONARY_STATUS = stringPreferencesKey("dictionary_status")
val SEARCH_HISTORY = stringPreferencesKey("search_history")
}
private companion object {
const val MAX_SEARCH_HISTORY_ENTRIES = 100
}
private val json = Json { ignoreUnknownKeys = true }
@@ -67,17 +49,6 @@ class PreferencesManager(private val context: Context) {
}
}
val searchBarPosition: Flow<SearchBarPosition> = context.dataStore.data
.map { preferences ->
SearchBarPosition.fromStorage(preferences[PreferencesKeys.SEARCH_BAR_POSITION])
}
suspend fun saveSearchBarPosition(position: SearchBarPosition) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.SEARCH_BAR_POSITION] = position.name
}
}
val dictionarySources: Flow<List<DictionarySource>> = context.dataStore.data
.map { preferences ->
val sourcesJson = preferences[PreferencesKeys.DICTIONARY_SOURCES]
@@ -108,25 +79,6 @@ class PreferencesManager(private val context: Context) {
}
}
suspend fun dictionaryStatus(): String? =
context.dataStore.data.first()[PreferencesKeys.DICTIONARY_STATUS]
suspend fun saveDictionaryStatus(status: String) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.DICTIONARY_STATUS] = status
}
}
suspend fun sectionExpansionStates(): SectionExpansionStates =
context.dataStore.data.first().let { preferences ->
SectionExpansionStates(
isThemeExpanded = preferences[PreferencesKeys.IS_THEME_EXPANDED] ?: false,
isLanguageExpanded = preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false,
isSearchBarExpanded = preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] ?: false,
isDictionariesExpanded = preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true,
)
}
suspend fun setDictionaryActive(path: String, isActive: Boolean) {
context.dataStore.edit { preferences ->
val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS]
@@ -165,11 +117,6 @@ class PreferencesManager(private val context: Context) {
preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false
}
val isSearchBarExpanded: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] ?: false
}
val isDictionariesExpanded: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true
@@ -180,7 +127,6 @@ class PreferencesManager(private val context: Context) {
when (key) {
"theme" -> preferences[PreferencesKeys.IS_THEME_EXPANDED] = isExpanded
"language" -> preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] = isExpanded
"search_bar" -> preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] = isExpanded
"dictionaries" -> preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] = isExpanded
}
}
@@ -227,51 +173,6 @@ class PreferencesManager(private val context: Context) {
}
}
val searchHistory: Flow<List<String>> = context.dataStore.data
.map { preferences -> preferences[PreferencesKeys.SEARCH_HISTORY] }
.distinctUntilChanged()
.map(::decodeSearchHistory)
suspend fun addSearchHistoryEntry(query: String) {
context.dataStore.edit { preferences ->
val current = decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
.toMutableList()
current.removeAll { it.equals(query, ignoreCase = true) }
current.add(0, query)
preferences[PreferencesKeys.SEARCH_HISTORY] =
json.encodeToString(current.take(MAX_SEARCH_HISTORY_ENTRIES))
}
}
suspend fun removeSearchHistoryEntry(query: String) {
context.dataStore.edit { preferences ->
val current = decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
.toMutableList()
if (current.removeAll { it == query }) {
if (current.isEmpty()) {
preferences.remove(PreferencesKeys.SEARCH_HISTORY)
} else {
preferences[PreferencesKeys.SEARCH_HISTORY] = json.encodeToString(current)
}
}
}
}
suspend fun clearSearchHistory() {
context.dataStore.edit { preferences ->
preferences.remove(PreferencesKeys.SEARCH_HISTORY)
}
}
private fun decodeSearchHistory(value: String?): List<String> {
if (value == null) return emptyList()
return try {
json.decodeFromString<List<String>>(value)
} catch (_: Exception) {
emptyList()
}
}
suspend fun sanitizeDictionarySources() {
context.dataStore.edit { preferences ->
val currentSources = decodeSources(
@@ -180,19 +180,14 @@ internal fun parseDslArticleContent(rawContent: String): String {
while (i < len) {
val lineStart = i
while (i < len && rawContent[i] != '\n' && rawContent[i] != '\r') i++
while (i < len && rawContent[i] != '\n') i++
val lineEnd = i
if (i < len && rawContent[i] == '\r') {
i++
if (i < len && rawContent[i] == '\n') i++
} else if (i < len && rawContent[i] == '\n') {
i++
}
if (i < len && rawContent[i] == '\n') i++
if (lineStart == lineEnd && i >= len) break
val line = rawContent.substring(lineStart, lineEnd)
val trimmed = line.trim()
val trimmed = line.trimEnd()
if (trimmed.isEmpty()) continue
if (skippingHeadwords) {
@@ -1,8 +1,5 @@
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(
@@ -120,12 +117,12 @@ object DslHeadwordParser {
}
private fun reconstructDisplayPrefix(value: String): String {
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)
val reconstructed = removeCurlyBraceDelimiters(value)
val formattingTagIndex = firstFormattingTagIndex(reconstructed)
return if (formattingTagIndex >= 0) {
reconstructed.substring(0, formattingTagIndex).trim()
} else {
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? {
var index = 0
while (index < value.length) {
@@ -334,6 +346,14 @@ 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 = 7
const val INDEX_VERSION = 6
const val BUFFER_SIZE = 1048576
const val BATCH_BUFFER_SIZE = 524288
private const val INDEX_SORT_PARALLELISM = 1
@@ -454,7 +454,11 @@ class IndexSearcher(private val context: Context) {
val originalWord = readPooledString(source, source.readUnsignedShort())
val offset = ArticleOffset(source.readLong())
val length = ArticleLength(source.readInt())
val isAlias = metadata.version >= 6 && source.readUnsignedByte() and ENTRY_FLAG_ALIAS != 0
val isAlias = if (metadata.version >= 6) {
source.readUnsignedByte() and ENTRY_FLAG_ALIAS != 0
} else {
false
}
return IndexEntry(word, originalWord, offset, length, isAlias = isAlias)
}
@@ -468,11 +472,8 @@ class IndexSearcher(private val context: Context) {
private fun openSource(pathOrUri: String): RandomAccessSource {
val file = File(pathOrUri)
return if (file.exists()) {
FileSource(file)
} else {
throw DictionaryError.FileSystemError.NotFound(pathOrUri)
}
if (!file.exists()) throw DictionaryError.FileSystemError.NotFound(pathOrUri)
return FileSource(file)
}
private fun openInputStream(pathOrUri: String): java.io.InputStream {
val file = File(pathOrUri)
@@ -112,20 +112,20 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
maxResults: Int
): List<IndexEntry> = withContext(Dispatchers.IO) {
val allResults = mutableListOf<IndexEntry>()
for ((name, path, indexPath, _, _, metadata) in dictionaries) {
for (dict in dictionaries) {
try {
val resolvedIndexPath = resolveIndexPath(indexPath)
val entry = indexSearcher.findFirstEntry(resolvedIndexPath, query)
val indexPath = resolveIndexPath(dict.indexPath)
val entry = indexSearcher.findFirstEntry(indexPath, query)
if (entry != null) {
allResults.add(entry.withDictionary(metadata.name.ifBlank { name }, path))
allResults.add(entry.withDictionary(dict.metadata.name.ifBlank { dict.name }, dict.path))
}
if (allResults.size >= maxResults) break
} catch (e: OutOfMemoryError) {
Log.e(TAG, "search() - OOM for $indexPath", e)
Log.e(TAG, "search() - OOM for ${dict.indexPath}", e)
trimMemory(80)
break
} catch (e: Exception) {
Log.w(TAG, "search() - error for $indexPath: ${e.message}")
Log.w(TAG, "search() - error for ${dict.indexPath}: ${e.message}")
}
}
allResults
@@ -182,7 +182,7 @@ class LocalDictionaryRepository(
progressTracker = ProgressTracker(filesToScan)
existingDictionaries.forEach { dict ->
progressTracker?.updateFileProgress(File(dict.path).name, 1.0f)
progressTracker?.updateFileProgress(dict.name, 1.0f)
}
mutableIndexingProgress.emit(
@@ -538,12 +538,12 @@ class LocalDictionaryRepository(
val dictionaries = mutableDictionaries.value
if (dictionaries.isEmpty()) return@withContext
for ((_, _, indexPath) in dictionaries) {
for (dict in dictionaries) {
try {
val resolvedIndexPath = resolveIndexPath(indexPath)
val indexPath = resolveIndexPath(dict.indexPath)
val indexSearcher = engine.getIndexSearcher()
if (!indexSearcher.isMetadataLoaded(resolvedIndexPath)) {
engine.preloadIndexMetadata(resolvedIndexPath)
if (!indexSearcher.isMetadataLoaded(indexPath)) {
engine.preloadIndexMetadata(indexPath)
}
yield()
} catch (_: Exception) { /* Ignore preload failure */ }
@@ -7,7 +7,6 @@ import com.example.research.common.util.FileStorageManager
import com.example.research.common.util.DocumentFileInfo
import com.example.research.core.domain.model.DictionarySource
import com.example.research.feature.download.config.DictionaryConfig
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
@@ -65,14 +64,7 @@ class DictionaryChecker(
val previousDate = DateUtils.getPreviousDateString()
val dictFiles = getDictFiles(folderUri, files)
val datedSources = enabledSources.filter { source ->
DictionarySource.hasDatePlaceholder(source.urlTemplate)
}
if (datedSources.isEmpty()) {
return@withContext true
}
val allHaveToday = datedSources.all { source ->
val allHaveToday = enabledSources.all { source ->
dictFiles.hasFileForSource(source, currentDate)
}
@@ -80,33 +72,22 @@ class DictionaryChecker(
return@withContext true
}
val allHaveYesterday = datedSources.all { source ->
val allHaveYesterday = enabledSources.all { source ->
dictFiles.hasFileForSource(source, previousDate)
}
if (allHaveYesterday) {
val serverHasToday = datedSources.any { source ->
val serverHasToday = enabledSources.any { source ->
isSourceAvailableOnServer(source, currentDate)
}
return@withContext !serverHasToday
}
val serverCanOfferNewer = datedSources.any { source ->
when (probeSource(source, currentDate)) {
SourceAvailability.AVAILABLE -> true
SourceAvailability.NOT_FOUND ->
probeSource(source, previousDate) == SourceAvailability.AVAILABLE
SourceAvailability.UNKNOWN -> false
}
}
return@withContext !serverCanOfferNewer
} catch (e: CancellationException) {
throw e
false
} catch (e: Exception) {
Log.e(TAG, "Error checking currency: ${e.message}", e)
true
false
}
}
@@ -54,17 +54,17 @@ class DictionaryCleaner(
for ((prefix, items) in parsedByPrefix) {
val latestDsl = latestDslDateByPrefix[prefix] ?: continue
val dslDates = dslDatesByPrefix[prefix].orEmpty()
for ((name, _, date, ext) in items) {
if (DateUtils.isDateBefore(date, latestDsl)) {
toDelete.add(name)
for (item in items) {
if (DateUtils.isDateBefore(item.date, latestDsl)) {
toDelete.add(item.name)
continue
}
if (ext in indexExtensions && date != latestDsl) {
toDelete.add(name)
if (item.ext in indexExtensions && item.date != latestDsl) {
toDelete.add(item.name)
continue
}
if (ext in indexExtensions && date !in dslDates) {
toDelete.add(name)
if (item.ext in indexExtensions && item.date !in dslDates) {
toDelete.add(item.name)
}
}
}
@@ -46,13 +46,14 @@ class DictionaryExtractor(
reportProgress(processedIndex.toFloat() / totalFiles)
}
for ((fileName, uri) in gzFiles) {
for (file in gzFiles) {
val fileName = file.name
val baseName = fileName.dropLast(DictionaryConfig.GZ_EXTENSION.length)
val lowerBaseName = baseName.lowercase()
if (baseName.isBlank() ||
lowerBaseName.endsWith(DictionaryConfig.GZ_EXTENSION) ||
lowerBaseName.endsWith(".txt")) {
ArchiveUtils.deleteFile(context, uri)
ArchiveUtils.deleteFile(context, file.uri)
markFileProcessed()
continue
}
@@ -64,19 +65,19 @@ class DictionaryExtractor(
val dslFileName = "$dslBaseName${DictionaryConfig.DSL_EXTENSION}"
val existingDslFile = fileStorageManager.findFile(folderUri, dslFileName)
if (existingDslFile != null && existingDslFile.exists()) {
ArchiveUtils.deleteFile(context, uri)
ArchiveUtils.deleteFile(context, file.uri)
markFileProcessed()
continue
}
val dslUri = fileStorageManager.createFileInFolder(folderUri, dslFileName, overwrite = false)
?: continue
val extractResult = ArchiveUtils.extractGzWithBom(context, uri, dslUri)
val extractResult = ArchiveUtils.extractGzWithBom(context, file.uri, dslUri)
if (extractResult.isSuccess) {
val extractedFile = fileStorageManager.getDocumentFile(dslUri)
if (extractedFile != null && extractedFile.exists()) {
if (extractedFile.length() == 0L) {
ArchiveUtils.deleteFile(context, dslUri)
ArchiveUtils.deleteFile(context, uri)
ArchiveUtils.deleteFile(context, file.uri)
markFileProcessed()
continue
}
@@ -91,7 +92,7 @@ class DictionaryExtractor(
ArchiveUtils.deleteFile(context, dslUri)
}
}
ArchiveUtils.deleteFile(context, uri)
ArchiveUtils.deleteFile(context, file.uri)
} else {
Log.e("DictionaryExtractor", "File extraction error: $fileName")
ArchiveUtils.deleteFile(context, dslUri)
@@ -99,11 +100,12 @@ class DictionaryExtractor(
markFileProcessed()
}
for ((dslFileName, uri) in rawDslFiles) {
for (file in rawDslFiles) {
val dslFileName = file.name
val dzFileName = "$dslFileName.dz"
val existingDz = fileStorageManager.findFile(folderUri, dzFileName)
if (existingDz != null && existingDz.exists()) {
ArchiveUtils.deleteFile(context, uri)
ArchiveUtils.deleteFile(context, file.uri)
markFileProcessed()
continue
}
@@ -111,11 +113,11 @@ class DictionaryExtractor(
val fileBaseProgress = processedIndex.toFloat() / totalFiles
val fileProgressWeight = 1f / totalFiles
reportProgress(fileBaseProgress)
val compressResult = ensureDslDictZip(uri, dslFileName, folderUri) { compressProgress ->
val compressResult = ensureDslDictZip(file.uri, dslFileName, folderUri) { compressProgress ->
reportProgress(fileBaseProgress + (compressProgress * fileProgressWeight))
}
if (compressResult.isSuccess) {
ArchiveUtils.deleteFile(context, uri)
ArchiveUtils.deleteFile(context, file.uri)
}
markFileProcessed()
}
@@ -128,10 +130,11 @@ class DictionaryExtractor(
!lowerName.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
!lowerName.endsWith(".dsl.gz")
}
for ((dslFileName, uri) in remainingRawDslFiles) {
val compressResult = ensureDslDictZip(uri, dslFileName, folderUri, onProgress = null)
for (file in remainingRawDslFiles) {
val dslFileName = file.name
val compressResult = ensureDslDictZip(file.uri, dslFileName, folderUri, onProgress = null)
if (compressResult.isSuccess) {
ArchiveUtils.deleteFile(context, uri)
ArchiveUtils.deleteFile(context, file.uri)
}
}
@@ -149,18 +152,18 @@ class DictionaryExtractor(
try {
withContext(Dispatchers.IO + NonCancellable) {
val files = fileStorageManager.listFilesInFolder(folderUri)
for ((name, uri) in files) {
val n = name.lowercase()
for (file in files) {
val n = file.name.lowercase()
val isRawDsl = n.endsWith(DictionaryConfig.DSL_EXTENSION) &&
!n.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
!n.endsWith(".dsl.gz")
val isBareDz = n.endsWith(".dz") && !n.endsWith(DictionaryConfig.DICTZIP_EXTENSION)
when {
isBareDz -> ArchiveUtils.deleteFile(context, uri)
isBareDz -> ArchiveUtils.deleteFile(context, file.uri)
isRawDsl -> {
val pair = fileStorageManager.findFile(folderUri, "$name.dz")
val pair = fileStorageManager.findFile(folderUri, file.name + ".dz")
if (pair == null || !pair.exists()) {
ArchiveUtils.deleteFile(context, uri)
ArchiveUtils.deleteFile(context, file.uri)
}
}
}
@@ -144,8 +144,9 @@ class DictionaryRepository(
if (filesToDelete.isNotEmpty()) {
var deletedCount = 0
for ((fileName, uri) in filesToDelete) {
val docFile = fileStorageManager.getDocumentFile(uri)
for (file in filesToDelete) {
val fileName = file.name
val docFile = fileStorageManager.getDocumentFile(file.uri)
if (docFile != null && docFile.exists()) {
val deleted = docFile.delete()
if (deleted) {
@@ -88,12 +88,6 @@ class DictionaryForegroundService : Service() {
stopForTimeout(startId)
}
override fun onTaskRemoved(rootIntent: Intent?) {
cancelAllWork()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onTimeout(startId: Int, fgsType: Int) {
stopForTimeout(startId)
}
@@ -131,7 +125,7 @@ class DictionaryForegroundService : Service() {
startForegroundService()
}
ACTION_FINISH -> {
stopForegroundService(removeNotification = true)
stopForegroundService(removeNotification = false)
}
ACTION_STOP -> {
try {
@@ -10,7 +10,6 @@ import com.example.research.core.util.onError
import com.example.research.core.util.onSuccess
import com.example.research.core.util.sanitizeQuery
import com.example.research.data.paging.IndexEntryPagingSource
import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.ui.article.ArticleState
import com.example.research.ui.article.ArticleTextWarmUp
@@ -29,12 +28,12 @@ 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(
application: Application,
private val repository: LocalDictionaryRepository,
private val preferencesManager: PreferencesManager,
private val savedStateHandle: SavedStateHandle
) : AndroidViewModel(application) {
@@ -52,13 +51,6 @@ class SearchViewModel(
private val mutableErrorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = mutableErrorMessage.asStateFlow()
val searchHistory: StateFlow<List<String>> = preferencesManager.searchHistory
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
private val indexSearcher by lazy { repository.getIndexSearcher() }
private var indexWarmupJob: Job? = null
private var debounceSaveQueryJob: Job? = null
@@ -69,12 +61,10 @@ class SearchViewModel(
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
repository.dictionaries.map { dictList ->
dictList.filter { it.isActive }
}.distinctUntilChanged { previous, current ->
previous.map { it.indexPath } == current.map { it.indexPath }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = repository.dictionaries.value.filter { it.isActive }
initialValue = emptyList()
)
init {
@@ -132,24 +122,20 @@ class SearchViewModel(
SearchAction.ClearQuery -> {
mutableSearchQuery.value = ""
savedStateHandle[KEY_SEARCH_QUERY] = ""
clearArticle()
}
is SearchAction.ArticleClicked -> {
articleHistory.clear()
recordSearchHistory(action.entry.originalWord)
loadArticle(action.entry)
}
SearchAction.ClearArticle -> clearArticle()
is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word)
is SearchAction.HandleExternalSearch -> handleExternalSearch(action.text)
}
}
fun onSearchQueryChanged(query: String) {
val sanitized = query.sanitizeQuery().trim()
mutableSearchQuery.value = sanitized
if (sanitized.isBlank()) {
clearArticle()
}
// Debounce SavedStateHandle writes to reduce disk I/O
debounceSaveQueryJob?.cancel()
debounceSaveQueryJob = viewModelScope.launch {
@@ -169,27 +155,6 @@ class SearchViewModel(
mutableErrorMessage.value = null
}
fun removeSearchHistoryEntry(query: String) {
viewModelScope.launch {
preferencesManager.removeSearchHistoryEntry(query)
}
}
fun clearSearchHistory() {
viewModelScope.launch {
preferencesManager.clearSearchHistory()
}
}
private fun recordSearchHistory(query: String) {
val trimmed = query.trim()
if (trimmed.isBlank()) return
if (searchHistory.value.firstOrNull() == trimmed) return
viewModelScope.launch {
preferencesManager.addSearchHistoryEntry(trimmed)
}
}
fun saveScrollPosition(index: Int, offset: Int) {
mutableScrollPosition.value = index to offset
}
@@ -312,7 +277,6 @@ class SearchViewModel(
}
entry?.let {
currentArticle?.let(articleHistory::addLast)
recordSearchHistory(sanitizedWord)
loadArticle(it)
}
}.onError { message, exception ->
@@ -321,6 +285,14 @@ class SearchViewModel(
}
}
fun handleExternalSearch(text: String) {
viewModelScope.launch {
val sanitizedText = text.sanitizeQuery()
mutableSearchQuery.value = sanitizedText
searchAndLoadArticle(sanitizedText)
}
}
companion object {
private const val TAG = "SearchViewModel"
@@ -2,11 +2,9 @@ package com.example.research.ui.about
import android.content.Context
import androidx.annotation.RawRes
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
@Serializable
internal data class AboutLibrariesData(
@@ -76,8 +74,8 @@ internal object AboutLibrariesParser {
ignoreUnknownKeys = true
}
@OptIn(ExperimentalSerializationApi::class)
fun read(context: Context, @RawRes resourceId: Int): AboutLibrariesData =
context.resources.openRawResource(resourceId)
.use { stream -> json.decodeFromStream(stream) }
.bufferedReader()
.use { reader -> json.decodeFromString(reader.readText()) }
}
@@ -1,25 +1,21 @@
package com.example.research.ui.about
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@@ -28,35 +24,29 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalWindowInfo
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.AppBackButton
import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.ui.theme.AppWindowInsets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun AboutScreen(
onNavigateBack: () -> Unit
@@ -76,25 +66,18 @@ fun AboutScreen(
}
var expandedLibraryId by remember { mutableStateOf<String?>(null) }
var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) }
val onToggleLibrary = remember {
{ uniqueId: String ->
expandedLibraryId = if (expandedLibraryId == uniqueId) {
null
} else {
uniqueId
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.about_app_title)) },
navigationIcon = {
AppBackButton(
onClick = onNavigateBack,
modifier = Modifier.padding(start = 6.dp)
)
IconButton(onClick = onNavigateBack) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back),
contentDescription = stringResource(R.string.action_back)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
@@ -110,20 +93,24 @@ fun AboutScreen(
.semantics { testTagsAsResourceId = true }
.padding(padding)
) {
item(key = "about_header", contentType = "about_header") {
item(key = "about_header") {
AboutHeader(versionName = versionName)
}
items(
items = librariesData?.libraries.orEmpty(),
key = AboutLibrary::uniqueId,
contentType = { "about_library" },
) { library ->
AboutLibraryRow(
modifier = Modifier.animateItem(fadeInSpec = tween(150)),
library = library,
licenses = librariesData?.licenses.orEmpty(),
expanded = expandedLibraryId == library.uniqueId,
onToggle = onToggleLibrary,
onToggle = {
expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
null
} else {
library.uniqueId
}
},
onLicenseClick = { dialogLicense = it },
)
}
@@ -156,14 +143,13 @@ private fun AboutHeader(versionName: String) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.alignByBaseline()
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = versionName,
text = stringResource(R.string.version_format, versionName),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alignByBaseline()
modifier = Modifier.padding(bottom = 2.dp)
)
}
@@ -182,35 +168,30 @@ private fun AboutHeader(versionName: String) {
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AboutLibraryRow(
modifier: Modifier = Modifier,
library: AboutLibrary,
licenses: Map<String, AboutLicense>,
expanded: Boolean,
onToggle: (String) -> Unit,
onToggle: () -> Unit,
onLicenseClick: (AboutLicense) -> Unit,
) {
val uriHandler = LocalUriHandler.current
val website = library.website?.takeIf(String::isNotBlank)
val source = library.scm?.url?.takeIf(String::isNotBlank)
val background by animateColorAsState(
targetValue = if (expanded) {
Surface(
modifier = Modifier
.fillMaxWidth()
.testTag("about_library_row")
.semantics { testTagsAsResourceId = true },
color = if (expanded) {
MaterialTheme.colorScheme.surfaceContainerHighest
} else {
MaterialTheme.colorScheme.surface
},
label = "aboutLibraryRowBackground",
)
Surface(
modifier = modifier
.fillMaxWidth()
.testTag("about_library_row")
.semantics { testTagsAsResourceId = true },
color = background,
onClick = { onToggle(library.uniqueId) }
onClick = onToggle
) {
Column(
modifier = Modifier
@@ -310,81 +291,32 @@ private fun AboutLibraryRow(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LicenseDialog(
license: AboutLicense,
onDismiss: () -> Unit,
onOpenWebsite: (String) -> Unit,
) {
var open by remember { mutableStateOf(false) }
var wasOpen by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { open = true }
val scale by animateFloatAsState(
targetValue = if (open) 1f else 0.92f,
animationSpec = tween(180),
label = "licenseDialogScale",
)
val alpha by animateFloatAsState(
targetValue = if (open) 1f else 0f,
animationSpec = tween(180),
label = "licenseDialogAlpha",
)
LaunchedEffect(open) {
if (open) {
wasOpen = true
} else if (wasOpen) {
snapshotFlow { alpha }.first { it == 0f }
onDismiss()
}
}
BasicAlertDialog(
onDismissRequest = { open = false },
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
}
) {
val maxHeight = with(LocalDensity.current) {
LocalWindowInfo.current.containerSize.height.toDp()
} - 96.dp
Surface(
shape = AlertDialogDefaults.shape,
color = AlertDialogDefaults.containerColor,
tonalElevation = AlertDialogDefaults.TonalElevation,
) {
Column(modifier = Modifier.padding(24.dp).heightIn(max = maxHeight)) {
Text(
text = license.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = license.content ?: stringResource(R.string.license_text_unavailable),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
license.url?.takeIf(String::isNotBlank)?.let { url ->
TextButton(onClick = { onOpenWebsite(url) }) {
Text(stringResource(R.string.website))
}
}
TextButton(onClick = { open = false }) {
Text(stringResource(R.string.action_close))
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(license.name) },
text = {
Text(
text = license.content ?: stringResource(R.string.license_text_unavailable),
modifier = Modifier.verticalScroll(rememberScrollState())
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_close))
}
},
dismissButton = {
license.url?.takeIf(String::isNotBlank)?.let { url ->
TextButton(onClick = { onOpenWebsite(url) }) {
Text(stringResource(R.string.website))
}
}
}
}
)
}
@@ -196,7 +196,9 @@ private suspend fun PointerInputScope.detectReferenceTaps(
if (change == null) {
break
}
if ((change.position - downPosition).getDistance() > viewConfiguration.touchSlop) {
if (change.isConsumed ||
(change.position - downPosition).getDistance() > viewConfiguration.touchSlop
) {
canceled = true
}
if (!change.pressed) {
@@ -1,106 +1,51 @@
package com.example.research.ui.article
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.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
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.AnnotatedString
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.R
import com.example.research.common.ui.AppBackButton
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.ui.theme.AppWindowInsets
import com.example.research.feature.search.SearchAction
import com.example.research.ui.theme.Spacing
import com.example.research.ui.theme.AppWindowInsets
import com.example.research.feature.search.SearchViewModel
import androidx.compose.runtime.getValue
private const val MERGED_BLOCK_MAX_CHARS = 1500
private const val MERGED_BLOCK_MAX_ITEMS = 12
@Composable
fun ArticleRoute(
searchViewModel: SearchViewModel,
onNavigateBack: () -> Unit
) {
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
private fun mergeDslBlocks(blocks: List<DslBlock>): List<DslBlock> {
if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks
val merged = ArrayList<DslBlock>(blocks.size / 4)
var i = 0
while (i < blocks.size) {
val builder = AnnotatedString.Builder()
val refs = ArrayList<DslAnnotatedParser.LazyRef>()
var j = i
while (j < blocks.size) {
val block = blocks[j]
repeat(block.indent) { builder.append(' ') }
val paraStart = builder.length
builder.append(block.text)
block.lazyRefs.forEach { ref ->
refs.add(
ref.copy(
startIndex = ref.startIndex + paraStart,
endIndex = ref.endIndex + paraStart
)
)
}
val more = j + 1 < blocks.size &&
(j + 1 - i) < MERGED_BLOCK_MAX_ITEMS &&
builder.length < MERGED_BLOCK_MAX_CHARS
if (more) {
builder.append('\n')
}
j++
if (!more) {
break
}
}
merged.add(
DslBlock(
text = builder.toAnnotatedString(),
indent = 0,
lazyRefs = refs,
continuesPrevious = blocks[i].continuesPrevious
)
)
i = j
}
return merged
ArticleScreen(
articleState = articleState,
onNavigateBack = onNavigateBack,
onRefClick = { word ->
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(word))
},
onScrollPositionChanged = { index, offset ->
searchViewModel.updateArticleScrollPosition(index, offset)
},
)
}
@Composable
@@ -117,17 +62,9 @@ fun ArticleContent(
}
is ArticleState.Success -> {
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
val colorScheme = MaterialTheme.colorScheme
val typography = MaterialTheme.typography
val baseTextStyle = remember(colorScheme, typography) {
typography.bodyLarge.copy(
color = colorScheme.onSurface,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Proportional,
trim = LineHeightStyle.Trim.Both,
),
)
}
val baseTextStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface
)
val indentStep = 16.dp
// Use scroll position from ArticleState if available (for back navigation)
@@ -186,42 +123,35 @@ fun ArticleContent(
val isHeavyArticle =
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
val displayBlocks = remember(
articleState.dslContent,
baseTextStyle,
darkTheme
) {
val merged =
mergeDslBlocks(articleState.dslContent)
if (darkTheme) {
merged.map {
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
LazyColumn(
state = listState,
modifier = modifier
.fillMaxSize()
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
.testTag("article_content")
.semantics {
testTagsAsResourceId = true
}
} else {
merged
}
}
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
LazyColumn(
state = listState,
contentPadding = PaddingValues(top = Spacing.listItemVertical),
modifier = listModifier
.testTag("article_content")
.semantics {
testTagsAsResourceId = true
}
) {
itemsIndexed(
items = displayBlocks,
key = { index, _ ->
"$articleSaveKey:$index"
},
contentType = { _, _ -> "article_block" }
) { index, block ->
) {
itemsIndexed(
items = articleState.dslContent,
key = { index, _ ->
// Use stable key combining article save key and index
"$articleSaveKey:$index"
},
contentType = { _, _ -> "article_block" }
) { index, block ->
val followedByContinuation =
articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true
val textContent = @Composable {
Box(
modifier = Modifier
.padding(start = Spacing.contentStart + indentStep * block.indent)
.padding(
start = Spacing.iconStart + indentStep * block.indent
)
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
) {
ArticleLinkText(
block = block,
@@ -238,18 +168,13 @@ fun ArticleContent(
)
}
}
if (isHeavyArticle) {
textContent()
} else {
SelectionContainer { textContent() }
}
}
}
val articleListModifier = modifier
.fillMaxSize()
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
if (isHeavyArticle) {
articleList(articleListModifier)
} else {
SelectionContainer { articleList(articleListModifier) }
}
}
is ArticleState.Error -> {
Column(
@@ -271,7 +196,16 @@ fun ArticleContent(
}
}
null -> {
Box(modifier = modifier.fillMaxSize())
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.article_no_selected),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
@@ -281,73 +215,65 @@ fun ArticleTitleBar(
articleState: ArticleState?,
showBackButton: Boolean,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
compact: Boolean = false,
selectionReset: Int = 0,
modifier: Modifier = Modifier
) {
val title = when (articleState) {
is ArticleState.Loading -> articleState.title
is ArticleState.Success -> articleState.title
is ArticleState.Error -> articleState.title
null -> ""
null -> stringResource(R.string.article_no_selected)
}
val backButtonOffset = if (compact) {
val titleLineHeight = with(LocalDensity.current) {
MaterialTheme.typography.dictionaryTitleLarge.lineHeight.toDp()
}
Modifier.offset(y = (titleLineHeight - 48.dp) / 2 + 3.dp)
} else {
Modifier
}
val backIcon = painterResource(R.drawable.ic_arrow_back)
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = modifier.fillMaxWidth()
) {
Row(
Box(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
.then(
if (compact) {
Modifier.padding(vertical = Spacing.listItemVertical)
} else {
Modifier
.padding(top = Spacing.topBarPadding, bottom = Spacing.topBarPadding)
.height(Spacing.topBarHeight)
}
),
verticalAlignment = if (compact) Alignment.Top else Alignment.CenterVertically
.padding(
top = Spacing.topBarPadding,
bottom = Spacing.topBarPadding
)
.height(Spacing.topBarHeight)
) {
if (showBackButton) {
AppBackButton(
onClick = onNavigateBack,
Box(
modifier = Modifier
.padding(start = 10.dp)
.then(backButtonOffset)
)
}
key(selectionReset) {
SelectionContainer {
Text(
text = title,
style = MaterialTheme.typography.dictionaryTitleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(start = if (showBackButton) 0.dp else 16.dp)
.testTag("article_title")
.semantics { testTagsAsResourceId = true }
.align(Alignment.CenterStart)
.padding(start = Spacing.iconStart)
.size(24.dp)
.clickable(onClick = onNavigateBack),
contentAlignment = Alignment.Center
) {
Icon(
painter = backIcon,
contentDescription = stringResource(R.string.action_back),
modifier = Modifier.size(24.dp)
)
}
}
Text(
text = title,
style = MaterialTheme.typography.dictionaryTitleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = if (showBackButton) Spacing.searchTextStart else 16.dp)
.testTag("article_title")
.semantics { testTagsAsResourceId = true }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ArticleScreen(
articleState: ArticleState?,
@@ -355,31 +281,22 @@ internal fun ArticleScreen(
onRefClick: ((String) -> Unit)? = null,
onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
) {
val titleSelectionReset = remember { mutableIntStateOf(0) }
Box(
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { titleSelectionReset.intValue++ }
) {
Scaffold(
topBar = {
ArticleTitleBar(
articleState = articleState,
showBackButton = true,
onNavigateBack = onNavigateBack,
selectionReset = titleSelectionReset.intValue
)
},
contentWindowInsets = AppWindowInsets.contentWithNavigation
) { padding ->
ArticleContent(
Scaffold(
topBar = {
ArticleTitleBar(
articleState = articleState,
onNavigateBack = onNavigateBack,
onRefClick = onRefClick,
onScrollPositionChanged = onScrollPositionChanged,
modifier = Modifier.padding(padding)
showBackButton = true,
onNavigateBack = onNavigateBack
)
}
},
contentWindowInsets = AppWindowInsets.contentWithNavigation
) { padding ->
ArticleContent(
articleState = articleState,
onNavigateBack = onNavigateBack,
onRefClick = onRefClick,
onScrollPositionChanged = onScrollPositionChanged,
modifier = Modifier.padding(padding)
)
}
}
@@ -24,40 +24,6 @@ object DslAnnotatedParser {
"ref", "ex", "e", "trn", "com", "lang", "sup", "'"
)
private val DSL_NAMED_COLORS = mapOf(
"black" to Color(0xFF000000),
"silver" to Color(0xFFC0C0C0),
"gray" to Color(0xFF808080),
"grey" to Color(0xFF808080),
"lightgray" to Color(0xFFD3D3D3),
"lightgrey" to Color(0xFFD3D3D3),
"darkgray" to Color(0xFFA9A9A9),
"darkgrey" to Color(0xFFA9A9A9),
"white" to Color(0xFFFFFFFF),
"maroon" to Color(0xFF800000),
"red" to Color(0xFFFF0000),
"darkred" to Color(0xFF8B0000),
"crimson" to Color(0xFFDC143C),
"brown" to Color(0xFFA52A2A),
"orange" to Color(0xFFFFA500),
"pink" to Color(0xFFFFC0CB),
"purple" to Color(0xFF800080),
"violet" to Color(0xFFEE82EE),
"fuchsia" to Color(0xFFFF00FF),
"magenta" to Color(0xFFFF00FF),
"olive" to Color(0xFF808000),
"yellow" to Color(0xFFFFFF00),
"green" to Color(0xFF008000),
"darkgreen" to Color(0xFF006400),
"lime" to Color(0xFF00FF00),
"teal" to Color(0xFF008080),
"aqua" to Color(0xFF00FFFF),
"cyan" to Color(0xFF00FFFF),
"navy" to Color(0xFF000080),
"blue" to Color(0xFF0000FF),
"darkblue" to Color(0xFF00008B),
)
data class ColorScheme(
val secondaryText: Color,
val labelText: Color,
@@ -70,6 +36,11 @@ object DslAnnotatedParser {
val endIndex: Int
)
// Cache for applyLazyRefs results to avoid recreating AnnotatedString with links
// Size: 32 blocks - covers typical visible + prefetch range in LazyColumn
// Memory estimate: ~20KB per block average (AnnotatedString with LinkAnnotations)
// Total: ~640KB worst case
// LRU eviction automatic via LruCache
private val refCache = object : androidx.collection.LruCache<Pair<DslBlock, Int>, AnnotatedString>(32) {
override fun sizeOf(key: Pair<DslBlock, Int>, value: AnnotatedString): Int = 1
}
@@ -83,9 +54,9 @@ object DslAnnotatedParser {
val stack = ArrayDeque<SpanStyle>()
val refStack = ArrayDeque<Pair<Int, StringBuilder>>()
val lazyRefs = mutableListOf<LazyRef>()
var lastChar: Char? = null
var accentNext = false
var currentIndent = 0
var lastAppended: Char? = null
val exampleStyle = SpanStyle(color = colorScheme.secondaryText)
val labelStyle = SpanStyle(color = colorScheme.labelText)
val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline)
@@ -103,38 +74,16 @@ object DslAnnotatedParser {
}
fun flushBlock() {
val text = builder.toAnnotatedString()
if (text.any { !it.isWhitespace() }) {
if (builder.length > 0) {
result.add(DslBlock(
text = text,
text = builder.toAnnotatedString(),
indent = currentIndent,
lazyRefs = lazyRefs.toList()
))
builder = AnnotatedString.Builder()
stack.forEach { builder.pushStyle(it) }
lazyRefs.clear()
}
builder = AnnotatedString.Builder()
stack.forEach { builder.pushStyle(it) }
lazyRefs.clear()
lastAppended = null
}
fun appendChar(char: Char, hard: Boolean = false) {
if (char == ' ' && !hard) {
if (lastAppended == null || lastAppended == ' ') return
}
lastAppended = char
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
}
fun appendText(value: String) {
if (value.isEmpty()) return
builder.append(value)
if (refStack.isNotEmpty()) {
refStack.last().second.append(value)
}
lastAppended = value.last()
}
fun marginIndent(tagName: String): Int {
@@ -150,11 +99,20 @@ object DslAnnotatedParser {
when (val char = dsl[i]) {
'\\' -> {
if (i + 1 < length) {
appendChar(dsl[i + 1], hard = true)
val next = dsl[i + 1]
builder.append(next)
if (refStack.isNotEmpty()) {
refStack.last().second.append(next)
}
lastChar = next
i += 2
continue
}
appendChar(char)
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
lastChar = char
i++
}
'[' -> {
@@ -191,10 +149,6 @@ object DslAnnotatedParser {
accentNext = false
}
pop()
if (tagName.startsWith("m")) {
flushBlock()
currentIndent = 0
}
} else {
when (tagName) {
"'" -> {
@@ -207,15 +161,17 @@ object DslAnnotatedParser {
} else {
builder.append("\n")
}
lastChar = '\n'
}
"p" -> {
push(labelStyle.copy(fontStyle = FontStyle.Italic))
push(labelStyle)
}
"b" -> push(SpanStyle(fontWeight = FontWeight.Bold))
"m", "m0", "m1", "m2", "m3", "m4", "m5" -> {
if (lastAppended != null && !lastAppended!!.isWhitespace()) {
flushBlock()
if (lastChar != '\n' && lastChar != null) {
if (refStack.isEmpty()) flushBlock() else builder.append("\n")
}
lastChar = '\n'
currentIndent = marginIndent(tagName)
push(exampleStyle)
}
@@ -225,18 +181,7 @@ object DslAnnotatedParser {
refStack.addLast(builder.length to StringBuilder())
}
"ex", "e" -> push(exampleStyle)
"c" -> {
val colorName = if (spaceIdx >= 0) {
tagWithParams.substring(spaceIdx + 1).trim().lowercase()
} else {
null
}
push(when {
colorName == null -> labelStyle
else -> DSL_NAMED_COLORS[colorName]?.let { SpanStyle(color = it) } ?: SpanStyle()
})
}
"t", "trn", "lang", "sup" -> push(SpanStyle())
"c", "t", "trn", "lang", "sup" -> push(SpanStyle())
"com" -> push(SpanStyle(fontStyle = FontStyle.Italic, color = colorScheme.labelText))
}
}
@@ -244,23 +189,18 @@ object DslAnnotatedParser {
continue
}
}
appendChar(char)
i++
}
'\n' -> {
if (refStack.isEmpty()) {
flushBlock()
} else {
appendChar(char)
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
i++
}
'\t' -> {
appendText("\u2002")
lastChar = char
i++
}
else -> {
appendChar(char)
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
if (accentNext) {
builder.append('\u0301')
@@ -270,6 +210,7 @@ object DslAnnotatedParser {
accentNext = false
}
lastChar = char
i++
}
}
@@ -411,35 +352,6 @@ object DslAnnotatedParser {
linkText = Color(0xFF1E88E5),
)
private val DARK_THEME_COLORS: Map<Color, Color> = mapOf(
Color(0xFF000000) to Color(0xFFE8E6E3),
Color(0xFF000080) to Color(0xFF8FA8E0),
Color(0xFF00008B) to Color(0xFF8FA8E0),
Color(0xFF0000FF) to Color(0xFF7C9CE8),
Color(0xFF006400) to Color(0xFF66BB6A),
Color(0xFF008000) to Color(0xFF66BB6A),
Color(0xFF8B0000) to Color(0xFFE57373),
Color(0xFF800000) to Color(0xFFE57373),
Color(0xFF800080) to Color(0xFFBA68C8),
Color(0xFFA52A2A) to Color(0xFFBCAAA4),
Color(0xFF008080) to Color(0xFF4DB6AC),
Color(0xFF808000) to Color(0xFFC0CA33),
)
fun remapForDarkTheme(text: AnnotatedString): AnnotatedString {
if (text.spanStyles.none { it.item.color in DARK_THEME_COLORS }) return text
val builder = AnnotatedString.Builder(text.text)
text.spanStyles.forEach { range ->
val style = DARK_THEME_COLORS[range.item.color]
?.let { range.item.copy(color = it) } ?: range.item
builder.addStyle(style, range.start, range.end)
}
text.paragraphStyles.forEach { range ->
builder.addStyle(range.item, range.start, range.end)
}
return builder.toAnnotatedString()
}
// Allow external cache clearing (e.g., when dictionaries change)
fun clearRefCache() {
refCache.evictAll()
@@ -1,7 +1,5 @@
package com.example.research.ui.main
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -14,8 +12,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.theme.AppWindowInsets
import com.example.research.ui.theme.Spacing
import com.example.research.ui.main.components.MainTopBar
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
@@ -23,11 +20,9 @@ fun AdaptiveMainScreen(
searchState: TextFieldState,
canSearch: Boolean,
isLandscape: Boolean,
isSearchBarAtBottom: Boolean,
results: LazyPagingItems<IndexEntry>,
errorMessage: String?,
showDictionaryName: Boolean,
searchHistory: List<String>,
articleState: ArticleState?,
scrollPosition: Pair<Int, Int>,
onAction: (MainAction) -> Unit,
@@ -48,37 +43,21 @@ fun AdaptiveMainScreen(
)
}
val isQueryEmpty = searchState.text.isEmpty()
val isShowingDetail = scaffoldState.targetState.primary == PaneAdaptedValue.Expanded
val isTwoPane =
scaffoldState.targetState.primary == PaneAdaptedValue.Expanded &&
scaffoldState.targetState.secondary == PaneAdaptedValue.Expanded
val listEndPadding = if (isTwoPane) 8.dp else 16.dp
val searchBar: @Composable () -> Unit = {
MainSearchBar(
Column(modifier = Modifier.fillMaxSize()) {
MainTopBar(
searchState = searchState,
canSearch = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom,
searchHistory = searchHistory,
onAction = onAction,
onNavigateToSettings = onNavigateToSettings,
enabled = canSearch,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
onNavigateToSettings = onNavigateToSettings
)
}
Column(
modifier = Modifier
.fillMaxSize()
.then(
if (isSearchBarAtBottom) {
Modifier.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
} else {
Modifier
}
)
) {
if (!isSearchBarAtBottom) {
searchBar()
}
ListDetailPaneScaffold(
directive = PaneScaffoldDirective.Default.copy(
@@ -105,27 +84,20 @@ fun AdaptiveMainScreen(
.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
)
.padding(start = Spacing.contentStart, end = listEndPadding),
.padding(start = 32.dp, end = listEndPadding),
listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
)
},
detailPane = {
val titleSelectionReset = remember { mutableIntStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { titleSelectionReset.intValue++ }
.padding(bottom = 16.dp)
) {
ArticleTitleBar(
articleState = articleState,
showBackButton = isShowingDetail && articleState != null,
showBackButton = isShowingDetail,
onNavigateBack = onNavigateBackFromArticle,
compact = true,
selectionReset = titleSelectionReset.intValue,
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
@@ -138,21 +110,21 @@ fun AdaptiveMainScreen(
}
},
)
if (isSearchBarAtBottom) {
searchBar()
}
}
LaunchedEffect(articleState) {
val targetValue = if (articleState == null) {
ThreePaneScaffoldValue(
LaunchedEffect(articleState, isQueryEmpty) {
val targetValue = when {
articleState == null -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Hidden,
secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden,
)
} else {
ThreePaneScaffoldValue(
isQueryEmpty -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Expanded,
secondary = PaneAdaptedValue.Hidden,
tertiary = PaneAdaptedValue.Hidden,
)
else -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Expanded,
secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden,
@@ -5,7 +5,4 @@ sealed interface MainAction {
data object ClearQuery : MainAction
data object ClearError : MainAction
data class SaveScrollPosition(val index: Int, val offset: Int) : MainAction
data class HistoryEntryClicked(val query: String) : MainAction
data class RemoveHistoryEntry(val query: String) : MainAction
data object ClearHistory : MainAction
}
@@ -1,9 +1,7 @@
package com.example.research.ui.main
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
@@ -16,13 +14,11 @@ import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.research.ui.theme.AppWindowInsets
import com.example.research.ui.theme.Spacing
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.core.domain.model.IndexEntry
import com.example.research.feature.download.config.DictionaryConfig
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.feature.download.model.DownloadState
import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel
@@ -40,11 +36,9 @@ fun MainRoute(
val results = searchViewModel.searchResults.collectAsLazyPagingItems()
val errorMessage by searchViewModel.errorMessage.collectAsStateWithLifecycle()
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
val searchHistory by searchViewModel.searchHistory.collectAsStateWithLifecycle()
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
val isSearchBarAtBottom = settingsState.searchBarPosition == SearchBarPosition.BOTTOM
val scrollPosition = remember(searchViewModel) {
searchViewModel.scrollPosition.value
@@ -52,6 +46,17 @@ fun MainRoute(
val searchState = remember(searchViewModel) {
TextFieldState(initialText = searchViewModel.searchQuery.value)
}
val onMainAction = remember(searchViewModel) {
{ action: MainAction ->
when (action) {
is MainAction.QueryChanged -> searchViewModel.onSearchQueryChanged(action.query)
MainAction.ClearQuery -> searchViewModel.onAction(SearchAction.ClearQuery)
MainAction.ClearError -> searchViewModel.clearError()
is MainAction.SaveScrollPosition ->
searchViewModel.saveScrollPosition(action.index, action.offset)
}
}
}
LaunchedEffect(searchState, searchQuery) {
if (searchState.text.toString() != searchQuery) {
@@ -73,9 +78,13 @@ fun MainRoute(
val showDictionaryName = remember(settingsState) {
val activeDictionaries = settingsState.dictionaries.filter { it.isActive }
activeDictionaries.any { dict ->
val fileName = dict.path.substringAfterLast("/")
!DictionaryConfig.isDabkrsDictionary(fileName)
if (activeDictionaries.isEmpty()) {
false
} else {
activeDictionaries.any { dict ->
val fileName = dict.path.substringAfterLast("/")
!DictionaryConfig.isDabkrsDictionary(fileName)
}
}
}
@@ -83,38 +92,14 @@ fun MainRoute(
val isWide = maxWidth >= 600.dp
val isLandscape = maxWidth > maxHeight
val onMainAction = remember(searchViewModel, isWide) {
{ action: MainAction ->
when (action) {
is MainAction.QueryChanged -> searchViewModel.onSearchQueryChanged(action.query)
MainAction.ClearQuery -> searchViewModel.onAction(SearchAction.ClearQuery)
MainAction.ClearError -> searchViewModel.clearError()
is MainAction.SaveScrollPosition ->
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)
}
is MainAction.RemoveHistoryEntry ->
searchViewModel.removeSearchHistoryEntry(action.query)
MainAction.ClearHistory -> searchViewModel.clearSearchHistory()
}
}
}
if (isWide) {
AdaptiveMainScreen(
searchState = searchState,
canSearch = canSearch,
isLandscape = isLandscape,
isSearchBarAtBottom = isSearchBarAtBottom,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
searchHistory = searchHistory,
articleState = articleState,
scrollPosition = scrollPosition,
onAction = onMainAction,
@@ -135,11 +120,9 @@ fun MainRoute(
MainScreen(
searchState = searchState,
canSearch = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
searchHistory = searchHistory,
scrollPosition = scrollPosition,
onAction = onMainAction,
onArticleClick = onArticleClick,
@@ -149,81 +132,28 @@ 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
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(AppWindowInsets.statusBarsAndIme)
) {
SearchResultsPane(
searchState = searchState,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
scrollPosition = scrollPosition,
onAction = onAction,
onArticleClick = onArticleClick,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = Spacing.contentStart, end = 16.dp),
)
searchBar()
}
return
}
Scaffold(
topBar = { searchBar() },
topBar = {
MainTopBar(
searchState = searchState,
enabled = canSearch,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
onNavigateToSettings = onNavigateToSettings
)
},
contentWindowInsets = AppWindowInsets.navigationOnly
) { paddingValues ->
SearchResultsPane(
@@ -239,7 +169,7 @@ internal fun MainScreen(
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = Spacing.contentStart, end = 16.dp),
.padding(start = 32.dp, end = 16.dp),
)
}
}
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -51,7 +50,6 @@ internal fun SearchResultsPane(
var restoredScrollPosition by remember { mutableStateOf(false) }
var pendingScrollToTop by remember { mutableStateOf(false) }
var sawRefreshForPendingScroll by remember { mutableStateOf(false) }
var staleEntries by remember { mutableStateOf(emptyList<IndexEntry>()) }
val imeBottomPadding = with(LocalDensity.current) {
WindowInsets.ime.getBottom(this).toDp()
}
@@ -74,22 +72,9 @@ internal fun SearchResultsPane(
restoredScrollPosition = true
pendingScrollToTop = query.isNotBlank()
sawRefreshForPendingScroll = false
if (query.isBlank()) {
staleEntries = emptyList()
}
}
}
SideEffect {
val loaded = results.itemSnapshotList.items
if (results.loadState.refresh is LoadState.NotLoading && loaded.isNotEmpty()) {
staleEntries = loaded
}
}
val pendingReload = results.itemCount == 0 &&
results.loadState.refresh is LoadState.Loading
val shownStale = if (pendingReload && searchState.text.isNotBlank()) staleEntries else emptyList()
LaunchedEffect(results.loadState.refresh, pendingScrollToTop) {
if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) {
sawRefreshForPendingScroll = true
@@ -140,7 +125,6 @@ internal fun SearchResultsPane(
listState = listState,
showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(bottom = imeBottomPadding),
staleEntries = shownStale,
)
}
}
@@ -1,14 +1,9 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -22,106 +17,55 @@ import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import com.example.research.ui.theme.AppWindowInsets
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import 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
import com.example.research.ui.theme.AppWindowInsets
import com.example.research.ui.theme.Spacing
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.milliseconds
import com.example.research.ui.theme.Spacing
private val TopBarHeight = Spacing.topBarHeight
private val TopBarShape = RoundedCornerShape(28.dp)
private val HistorySheetChrome = 64.dp
private val HistorySheetMinHeight = 300.dp
@Composable
fun MainTopBar(
searchState: TextFieldState,
enabled: Boolean,
isSearchBarAtBottom: Boolean = false,
onQueryChange: (String) -> Unit,
onClearQuery: () -> Unit,
searchHistory: List<String>,
onHistoryEntryClick: (String) -> Unit,
onHistoryEntryRemove: (String) -> Unit,
onClearHistory: () -> Unit,
onNavigateToSettings: () -> Unit
) {
val searchIcon = painterResource(R.drawable.ic_search)
val closeIcon = painterResource(R.drawable.ic_close)
val historyIcon = painterResource(R.drawable.ic_manage_search)
val settingsIcon = painterResource(R.drawable.ic_settings)
val clearLabel = stringResource(R.string.clear_search)
val historyLabel = stringResource(R.string.recent_searches)
val focusManager = LocalFocusManager.current
var showHistorySheet by remember { mutableStateOf(false) }
var topBarBottomPx by remember { mutableIntStateOf(0) }
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { topBarBottomPx = it.boundsInWindow().bottom.roundToInt() }
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(
if (isSearchBarAtBottom) {
AppWindowInsets.horizontalSafeDrawing
} else {
AppWindowInsets.topAndHorizontalSafeDrawing
}
)
.then(
if (isSearchBarAtBottom) {
Modifier.windowInsetsPadding(AppWindowInsets.bottomNavigation)
} else {
Modifier
}
)
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
@@ -131,21 +75,9 @@ fun MainTopBar(
enabled = enabled,
onQueryChange = onQueryChange,
onClearQuery = onClearQuery,
searchIcon = searchIcon,
closeIcon = closeIcon,
clearLabel = clearLabel,
historyIcon = historyIcon,
historyLabel = historyLabel,
historyEnabled = searchHistory.isNotEmpty(),
onShowHistory = {
focusManager.clearFocus(force = true)
coroutineScope.launch {
withTimeoutOrNull(350.milliseconds) {
snapshotFlow { imeInsets.getBottom(density) > 0 }
.first { visible -> !visible }
}
showHistorySheet = !showHistorySheet
}
},
modifier = Modifier.weight(1f)
)
@@ -168,39 +100,18 @@ fun MainTopBar(
}
}
}
if (showHistorySheet && searchHistory.isNotEmpty()) {
SearchHistorySheet(
history = searchHistory,
isSearchBarAtBottom = isSearchBarAtBottom,
topBarBottomPx = topBarBottomPx,
onDismiss = { showHistorySheet = false },
onEntryClick = { entry ->
focusManager.clearFocus(force = true)
showHistorySheet = false
onHistoryEntryClick(entry)
},
onEntryRemove = onHistoryEntryRemove,
onClearAll = {
showHistorySheet = false
onClearHistory()
}
)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SearchField(
searchState: TextFieldState,
enabled: Boolean,
onQueryChange: (String) -> Unit,
onClearQuery: () -> Unit,
closeIcon: Painter,
searchIcon: androidx.compose.ui.graphics.painter.Painter,
closeIcon: androidx.compose.ui.graphics.painter.Painter,
clearLabel: String,
historyIcon: Painter,
historyLabel: String,
historyEnabled: Boolean,
onShowHistory: () -> Unit,
modifier: Modifier = Modifier
) {
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
@@ -223,16 +134,11 @@ private fun SearchField(
lineLimits = TextFieldLineLimits.SingleLine,
leadingIcon = {
Icon(
painter = historyIcon,
contentDescription = historyLabel,
tint = LocalContentColor.current.copy(
alpha = if (historyEnabled) 1f else 0.38f
),
painter = searchIcon,
contentDescription = null,
modifier = Modifier
.offset(x = 4.dp)
.size(28.dp)
.appTestTag("history_button")
.iconClickable(enabled = historyEnabled, onClick = onShowHistory)
.size(24.dp)
)
},
trailingIcon = if (searchState.text.isNotEmpty()) {
@@ -277,47 +183,3 @@ private fun SearchField(
.semantics { testTagsAsResourceId = true }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchHistorySheet(
history: List<String>,
isSearchBarAtBottom: Boolean,
topBarBottomPx: Int,
onDismiss: () -> Unit,
onEntryClick: (String) -> Unit,
onEntryRemove: (String) -> Unit,
onClearAll: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val density = LocalDensity.current
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val navigationBarsBottom = WindowInsets.navigationBars.getBottom(density)
val maxContentHeight = remember {
with(density) {
if (isSearchBarAtBottom) {
screenHeight - HistorySheetChrome - navigationBarsBottom.toDp()
} else {
screenHeight - topBarBottomPx.toDp()
}
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
modifier = Modifier.testTag("search_history_sheet")
) {
SearchHistoryList(
history = history,
onEntryClick = onEntryClick,
onEntryRemove = onEntryRemove,
onClearAll = onClearAll,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = HistorySheetMinHeight, max = maxContentHeight)
.padding(horizontal = 24.dp)
)
}
}
@@ -1,138 +0,0 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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
@Composable
fun SearchHistoryList(
history: List<String>,
onEntryClick: (String) -> Unit,
onEntryRemove: (String) -> Unit,
onClearAll: () -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(),
) {
LazyColumn(
modifier = modifier
.appTestTag("search_history"),
state = listState,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
item(key = "history_header", contentType = "history_header") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Spacing.listItemVertical),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.recent_searches),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.clear_search_history),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
softWrap = false,
modifier = Modifier
.padding(end = 18.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.appTestTag("clear_history")
.clickable { onClearAll() }
.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
items(
items = history,
key = { it },
contentType = { "history_entry" }
) { entry ->
SearchHistoryItem(
entry = entry,
onEntryClick = onEntryClick,
onEntryRemove = onEntryRemove
)
}
}
}
@Composable
private fun SearchHistoryItem(
entry: String,
onEntryClick: (String) -> Unit,
onEntryRemove: (String) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.appTestTag("search_history_entry")
.clickable { onEntryClick(entry) }
.padding(vertical = Spacing.listItemVertical),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_history),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(end = 16.dp)
.size(20.dp)
)
Text(
text = entry,
style = MaterialTheme.typography.dictionaryTitleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.width(Spacing.topBarHeight)
) {
Icon(
painter = painterResource(R.drawable.ic_close),
contentDescription = stringResource(R.string.remove_search_history_entry),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.appTestTag("remove_history_entry")
.iconClickable { onEntryRemove(entry) }
.size(20.dp)
)
}
}
}
@@ -29,11 +29,8 @@ fun SearchResultsList(
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
showDictionaryName: Boolean = true,
contentPadding: PaddingValues = PaddingValues(),
staleEntries: List<IndexEntry> = emptyList(),
contentPadding: PaddingValues = PaddingValues()
) {
val showStale = staleEntries.isNotEmpty() && results.itemCount == 0
val rowCount = if (showStale) staleEntries.size else results.itemCount
LazyColumn(
modifier = modifier
.testTag("search_results")
@@ -43,18 +40,11 @@ fun SearchResultsList(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(
count = rowCount,
key = { index ->
val entry = if (showStale) staleEntries.getOrNull(index)
else if (index in 0 until results.itemCount) results.peek(index) else null
entry?.let { "${it.dictionaryPath}:${it.offset.value}:${it.word}" }
?: "search_result_$index"
},
contentType = { "search_result" }
count = results.itemCount,
key = results.itemKey { "${it.dictionaryPath}:${it.offset.value}:${it.word}" },
contentType = results.itemContentType { "search_result" }
) { index ->
val entry = if (showStale) staleEntries.getOrNull(index)
else if (index in 0 until results.itemCount) results.peek(index) else null
if (entry != null) {
results[index]?.let { entry ->
SearchResultItem(
entry = entry,
resultIndex = index,
@@ -1,29 +1,16 @@
package com.example.research.ui.navigation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.*
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel
import com.example.research.ui.about.AboutScreen
import com.example.research.ui.article.ArticleScreen
import com.example.research.ui.article.ArticleState
import com.example.research.ui.article.ArticleRoute
import com.example.research.ui.main.MainRoute
import com.example.research.ui.settings.SettingsRoute
import com.example.research.ui.settings.SettingsViewModel
@@ -38,6 +25,7 @@ enum class Screen {
Home, Settings, Article, About
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AppNavigation(
searchViewModel: SearchViewModel,
@@ -84,12 +72,6 @@ fun AppNavigation(
.windowSizeClass
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
LaunchedEffect(isWideScreen) {
if (isWideScreen) {
screenStack.removeAll { it == Screen.Article }
}
}
BackHandler(enabled = screenStack.size > 1 && !(currentScreen == Screen.Settings && isOperationActive)) {
if (currentScreen == Screen.Article) {
if (searchViewModel.navigateBackFromArticle()) {
@@ -103,33 +85,14 @@ fun AppNavigation(
}
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
var lastShownArticle by remember { mutableStateOf<ArticleState?>(null) }
LaunchedEffect(articleState) {
if (articleState != null) lastShownArticle = articleState
}
val effectiveScreen = when {
currentScreen == Screen.About -> Screen.About
currentScreen == Screen.Article && articleState == null -> if (shouldShowSettings) Screen.Settings else Screen.Home
shouldShowSettings -> Screen.Settings
currentScreen == Screen.Settings -> Screen.Settings
else -> Screen.Home
else -> currentScreen
}
Box {
AnimatedContent(
targetState = effectiveScreen,
transitionSpec = {
val forward = initialState.ordinal < targetState.ordinal
(
fadeIn(tween(180)) +
slideInHorizontally(tween(180)) { if (forward) it / 3 else -it / 3 }
).togetherWith(
fadeOut(tween(180)) +
slideOutHorizontally(tween(180)) { if (forward) -it / 3 else it / 3 }
)
},
label = "screenTransition",
) { screen ->
when (screen) {
when (effectiveScreen) {
Screen.Home -> {
MainRoute(
searchViewModel = searchViewModel,
@@ -160,6 +123,18 @@ fun AppNavigation(
onNavigateToAbout = { screenStack.add(Screen.About) }
)
}
Screen.Article -> {
ArticleRoute(
searchViewModel = searchViewModel,
onNavigateBack = {
if (searchViewModel.navigateBackFromArticle()) {
return@ArticleRoute
}
searchViewModel.onAction(SearchAction.ClearArticle)
if (screenStack.size > 1) screenStack.removeAt(screenStack.lastIndex)
}
)
}
Screen.About -> {
AboutScreen(
onNavigateBack = {
@@ -167,36 +142,5 @@ fun AppNavigation(
}
)
}
Screen.Article -> {}
}
}
AnimatedVisibility(
visible = currentScreen == Screen.Article && articleState != null,
enter = fadeIn(tween(180)) + slideInHorizontally(tween(180)) { it / 3 },
exit = fadeOut(tween(180)) + slideOutHorizontally(tween(180)) { it / 3 },
label = "articleOverlay",
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface
) {
ArticleScreen(
articleState = articleState ?: lastShownArticle,
onNavigateBack = {
if (!searchViewModel.navigateBackFromArticle()) {
searchViewModel.onAction(SearchAction.ClearArticle)
if (screenStack.size > 1) screenStack.removeAt(screenStack.lastIndex)
}
},
onRefClick = { word ->
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(word))
},
onScrollPositionChanged = { index, offset ->
searchViewModel.updateArticleScrollPosition(index, offset)
},
)
}
}
}
}
@@ -3,11 +3,6 @@ package com.example.research.ui.settings
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -21,6 +16,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
@@ -45,18 +41,17 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.example.research.R
import com.example.research.common.ui.AppBackButton
import com.example.research.common.ui.appTestTag
import com.example.research.common.ui.components.CollapsibleHeader
import com.example.research.common.ui.components.SectionCard
import com.example.research.ui.settings.components.DictionaryManagement
import com.example.research.ui.settings.components.LanguageSelectionSection
import com.example.research.ui.settings.components.SearchBarPositionSection
import com.example.research.ui.settings.components.ThemeSelectionSection
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsRoute(
viewModel: SettingsViewModel,
@@ -126,10 +121,12 @@ fun SettingsScreen(
title = { Text(stringResource(R.string.settings_title)) },
navigationIcon = {
if (showBackButton) {
AppBackButton(
onClick = onNavigateBack,
modifier = Modifier.padding(start = 6.dp)
)
IconButton(onClick = onNavigateBack) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back),
contentDescription = stringResource(R.string.action_back)
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -238,24 +235,6 @@ fun SettingsScreen(
}
}
item(key = "search_bar", contentType = "settings-section") {
CollapsibleSettingsSection(
title = stringResource(R.string.search_bar_position),
iconRes = R.drawable.ic_search,
testTag = "search_bar_section",
isExpanded = uiState.isSearchBarExpanded,
onToggleExpanded = { onEvent(SettingsUiEvent.SectionToggled("search_bar", !uiState.isSearchBarExpanded)) },
) {
SearchBarPositionSection(
currentPosition = uiState.searchBarPosition,
onPositionChanged = { position ->
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onEvent(SettingsUiEvent.SearchBarPositionChanged(position))
}
)
}
}
item(key = "bottom-spacer", contentType = "spacer") {
Spacer(modifier = Modifier.height(32.dp))
}
@@ -287,11 +266,7 @@ private fun CollapsibleSettingsSection(
testTag = testTag
)
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
if (isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -3,7 +3,6 @@ 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
@@ -14,10 +13,8 @@ import com.example.research.feature.download.model.DownloadState
data class SettingsUiState(
val theme: AppTheme = AppTheme.SYSTEM,
val language: String = "system",
val searchBarPosition: SearchBarPosition = SearchBarPosition.TOP,
val isThemeExpanded: Boolean = false,
val isLanguageExpanded: Boolean = false,
val isSearchBarExpanded: Boolean = false,
val isDictionariesExpanded: Boolean = true,
val dictionaries: List<Dictionary> = emptyList(),
val dictionaryStatus: DictionaryStatus = DictionaryStatus.Unknown,
@@ -33,7 +30,6 @@ data class SettingsUiState(
sealed interface SettingsUiEvent {
data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent
data class LanguageChanged(val language: String) : SettingsUiEvent
data class SearchBarPositionChanged(val position: SearchBarPosition) : SettingsUiEvent
data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent
data class DictionaryToggled(val path: String) : SettingsUiEvent
data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent
@@ -7,9 +7,7 @@ import androidx.lifecycle.viewModelScope
import com.example.research.DictionaryStatus
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
@@ -18,7 +16,6 @@ import com.example.research.core.domain.usecase.DictionarySourceValidator
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
import com.example.research.core.util.OperationResult
import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.local.preferences.SectionExpansionStates
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.model.DownloadState
@@ -47,13 +44,11 @@ private data class DictionaryStateInputs(
private data class OperationStateInputs(
val downloadState: DownloadState,
val language: String,
val searchBarPosition: SearchBarPosition,
val importState: ImportState,
val dictionaryStatus: DictionaryStatus,
val hasCompletedStartupScan: Boolean,
val isThemeExpanded: Boolean,
val isLanguageExpanded: Boolean,
val isSearchBarExpanded: Boolean,
val isDictionariesExpanded: Boolean,
)
@@ -66,8 +61,6 @@ class SettingsViewModel(
private val dictionaryImportManager: DictionaryImportManager,
progressStateHolder: DictionaryProgressStateHolder,
appVersion: String,
initialSectionExpansion: SectionExpansionStates,
initialSearchBarPosition: SearchBarPosition,
) : AndroidViewModel(application) {
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
@@ -75,16 +68,7 @@ class SettingsViewModel(
validator = DictionarySourceValidator(),
)
private val _uiState = MutableStateFlow(
SettingsUiState(
appVersion = appVersion,
isThemeExpanded = initialSectionExpansion.isThemeExpanded,
isLanguageExpanded = initialSectionExpansion.isLanguageExpanded,
isSearchBarExpanded = initialSectionExpansion.isSearchBarExpanded,
isDictionariesExpanded = initialSectionExpansion.isDictionariesExpanded,
searchBarPosition = initialSearchBarPosition,
)
)
private val _uiState = MutableStateFlow(SettingsUiState(appVersion = appVersion))
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
val progressSnapshot = progressStateHolder.progressSnapshot
@@ -102,32 +86,14 @@ class SettingsViewModel(
private var cancelRefreshPending = false
private var errorStateHandled = false
private var successStateHandled = false
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
init {
setupStateObservation()
observeDictionariesForSeedFlag()
persistDictionaryStatus()
initialize()
}
private fun persistDictionaryStatus() {
viewModelScope.launch {
dictionaryStatus.collect { status ->
val persistedName = when (status) {
DictionaryStatus.UpToDate -> "UpToDate"
DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
DictionaryStatus.Empty -> "Empty"
else -> null
}
if (persistedName != null) {
preferencesManager.saveDictionaryStatus("$persistedName|${DateUtils.getCurrentDateString()}")
}
}
}
}
private fun observeDictionariesForSeedFlag() {
viewModelScope.launch {
localDictionaryRepository.dictionaries.collect { dictionaries ->
@@ -140,6 +106,8 @@ class SettingsViewModel(
private fun setupStateObservation() {
viewModelScope.launch {
var wasIndexing = false
val dictionaryStateInputs = combine(
preferencesManager.themeMode.map { mode ->
try { AppTheme.valueOf(mode) } catch (_: Exception) { AppTheme.SYSTEM }
@@ -158,48 +126,45 @@ class SettingsViewModel(
val operationStateInputs = combine(
downloadManager.downloadState,
language,
preferencesManager.searchBarPosition,
dictionaryImportManager.importState,
dictionaryStatus,
hasCompletedStartupScan,
preferencesManager.isThemeExpanded,
preferencesManager.isLanguageExpanded,
preferencesManager.isSearchBarExpanded,
preferencesManager.isDictionariesExpanded,
) { params: Array<Any> ->
@Suppress("UNCHECKED_CAST")
OperationStateInputs(
downloadState = params[0] as DownloadState,
language = params[1] as String,
searchBarPosition = params[2] as SearchBarPosition,
importState = params[3] as ImportState,
dictionaryStatus = params[4] as DictionaryStatus,
hasCompletedStartupScan = params[5] as Boolean,
isThemeExpanded = params[6] as Boolean,
isLanguageExpanded = params[7] as Boolean,
isSearchBarExpanded = params[8] as Boolean,
isDictionariesExpanded = params[9] as Boolean,
importState = params[2] as ImportState,
dictionaryStatus = params[3] as DictionaryStatus,
hasCompletedStartupScan = params[4] as Boolean,
isThemeExpanded = params[5] as Boolean,
isLanguageExpanded = params[6] as Boolean,
isDictionariesExpanded = params[7] as Boolean,
)
}
combine(dictionaryStateInputs, operationStateInputs) { dictionaryState, operationState ->
// Handle indexing completion side effect
val isIndexing = dictionaryState.indexingProgress.isIndexing
if (wasIndexing && !isIndexing) {
if (isDownloadInProgress) {
isDownloadInProgress = false
dictionaryStatus.value = DictionaryStatus.UpToDate
}
}
wasIndexing = isIndexing
when (operationState.downloadState) {
is DownloadState.Loading, is DownloadState.Extracting -> {
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
@@ -248,6 +213,7 @@ class SettingsViewModel(
refreshDictionaryStatus()
}
}
else -> {}
}
SettingsUiState(
@@ -257,7 +223,6 @@ class SettingsViewModel(
indexingProgress = IndexingProgress(isIndexing = isIndexing),
downloadState = operationState.downloadState,
language = operationState.language,
searchBarPosition = operationState.searchBarPosition,
importState = operationState.importState,
dictionaryStatus = operationState.dictionaryStatus,
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
@@ -266,7 +231,6 @@ class SettingsViewModel(
!operationState.importState.isActive,
isThemeExpanded = operationState.isThemeExpanded,
isLanguageExpanded = operationState.isLanguageExpanded,
isSearchBarExpanded = operationState.isSearchBarExpanded,
isDictionariesExpanded = operationState.isDictionariesExpanded,
appVersion = _uiState.value.appVersion,
)
@@ -277,15 +241,9 @@ class SettingsViewModel(
}
private fun initialize() {
var scanCompleted = false
viewModelScope.launch {
try {
scanCompleted = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
scanDirectoryInternal(preferencesManager.dictionaryPath) is OperationResult.Success
} else {
dictionaryStatus.value = DictionaryStatus.UpToDate
false
}
scanDirectoryInternal(preferencesManager.dictionaryPath)
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "Initial scan failed", e)
dictionaryStatus.value = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
@@ -302,11 +260,9 @@ class SettingsViewModel(
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
}
hasCompletedStartupScan.value = true
if (scanCompleted) {
preferencesManager.setHadNoDictionaries(
localDictionaryRepository.dictionaries.value.isEmpty()
)
}
preferencesManager.setHadNoDictionaries(
localDictionaryRepository.dictionaries.value.isEmpty()
)
}
}
}
@@ -315,7 +271,6 @@ class SettingsViewModel(
when (event) {
is SettingsUiEvent.ThemeChanged -> setTheme(event.theme)
is SettingsUiEvent.LanguageChanged -> saveLanguage(event.language)
is SettingsUiEvent.SearchBarPositionChanged -> saveSearchBarPosition(event.position)
is SettingsUiEvent.SectionToggled -> saveSectionExpanded(event.key, event.isExpanded)
is SettingsUiEvent.DictionaryToggled -> toggleDictionaryActive(event.path)
is SettingsUiEvent.DictionaryDeleted -> deleteDictionary(event.dictionary)
@@ -388,12 +343,6 @@ class SettingsViewModel(
}
}
private fun saveSearchBarPosition(position: SearchBarPosition) {
viewModelScope.launch {
preferencesManager.saveSearchBarPosition(position)
}
}
private fun saveLanguage(languageCode: String) {
viewModelScope.launch {
preferencesManager.saveLanguage(languageCode)
@@ -551,25 +500,6 @@ class SettingsViewModel(
): List<DictionarySource> =
DictionarySourceFileMatcher.installedSources(sources, dictionaries)
private suspend fun installedDictionariesAreCurrent(): Boolean {
val sources = preferencesManager.dictionarySources.first()
val dictionaries = localDictionaryRepository.dictionaries.first()
val datedEnabledSources = installedEnabledSources(sources, dictionaries)
.filter { DictionarySource.hasDatePlaceholder(it.urlTemplate) }
if (datedEnabledSources.isEmpty()) return true
val fileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
preferencesManager.dictionaryPath
) ?: return true
val today = DateUtils.getCurrentDateString()
return datedEnabledSources.all { source ->
val baseName = DictionarySource.extractFileName(source.urlTemplate, today)
.removeSuffix(".gz")
.removeSuffix(".dsl")
.removeSuffix(".dz")
fileNames.any { it.startsWith(baseName) }
}
}
private fun cancelDownload() {
// Cancel the ViewModel-side scan job so no further status recomputes
localDictionaryRepository.cancelIndexing()
@@ -622,24 +552,9 @@ class SettingsViewModel(
dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false
} else {
val savedStatus = preferencesManager.dictionaryStatus()
val savedStatusName = savedStatus?.substringBefore('|')
val savedStatusDate = savedStatus?.substringAfter('|', "")
val restoredStatus = when (savedStatusName) {
"UpToDate" -> DictionaryStatus.UpToDate
"NeedsUpdate" -> DictionaryStatus.NeedsUpdate
else -> null
}
if (restoredStatus != null &&
savedStatusDate == DateUtils.getCurrentDateString() &&
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }
) {
dictionaryStatus.value = restoredStatus
} else {
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
}
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
}
if (result.data > 0) {
@@ -1,17 +1,7 @@
package com.example.research.ui.settings.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -25,17 +15,15 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -90,11 +78,12 @@ fun DictionaryManagement(
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
}
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
val isBlockingMutations = isInProgress
// Invariant: the mutation-block flag is read only at state-read/gesture time (derivedStateOf,
// snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read
// (e.g. Modifier.alpha(if (isDeleteBlocked()) ...)) would invalidate every row on each flip.
val isMutationsBlockedState = rememberUpdatedState(isInProgress)
val isMutationsBlockedState = rememberUpdatedState(isBlockingMutations)
val isDeleteBlocked = remember { { isMutationsBlockedState.value } }
SectionCard(
@@ -121,11 +110,7 @@ fun DictionaryManagement(
testTag = "dictionary_header"
)
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically(animationSpec = tween(250)) + fadeIn(tween(250)),
exit = shrinkVertically(animationSpec = tween(200)) + fadeOut(tween(200)),
) {
if (isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -150,13 +135,13 @@ fun DictionaryManagement(
// Disable accessibility on background content during import to improve performance.
// The progress dialog remains accessible for cancellation.
val backgroundModifier = if (isInProgress) {
Modifier.semantics { hideFromAccessibility() }
val backgroundModifier = if (isBlockingMutations) {
Modifier.semantics { invisibleToUser() }
} else {
Modifier
}
if (dictionaries.isEmpty() && !isInProgress) {
if (dictionaries.isEmpty() && !isBlockingMutations) {
Column(
modifier = backgroundModifier
.fillMaxWidth()
@@ -178,7 +163,7 @@ fun DictionaryManagement(
)
}
} else {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isBlockingMutations && hasEnabledSources) {
DictionaryUpdateCard(
onStartDownload = onStartDownload,
modifier = backgroundModifier
@@ -195,7 +180,7 @@ fun DictionaryManagement(
isDeleteBlocked = isDeleteBlocked
)
if (!isInProgress) {
if (!isBlockingMutations) {
DictionaryActionButtons(
onAddSource = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -220,8 +205,7 @@ fun DictionaryManagement(
focusManager.clearFocus(force = true)
setShowUrlBottomSheet(false)
},
sheetState = sheetState,
contentWindowInsets = { WindowInsets.statusBars }
sheetState = sheetState
) {
DictionaryUrlInputContent(
existingSources = sources,
@@ -276,34 +260,18 @@ private fun DictionaryListSection(
modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false }
) {
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
LaunchedEffect(dictionaries) {
val visiblePaths = dictionaries.mapTo(mutableSetOf(), Dictionary::path)
appearedPaths = appearedPaths intersect visiblePaths
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
dictionaries.forEach { dictionary ->
key(dictionary.path) {
val hasAppeared = dictionary.path in appearedPaths
val rowAlpha by animateFloatAsState(
targetValue = if (hasAppeared) 1f else 0f,
animationSpec = tween(150),
label = "dictionaryRowAppear",
DictionaryListItem(
dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) },
isDeleteBlocked = isDeleteBlocked
)
LaunchedEffect(dictionary.path) {
appearedPaths = appearedPaths + dictionary.path
}
Box(modifier = Modifier.alpha(rowAlpha)) {
DictionaryListItem(
dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) },
isDeleteBlocked = isDeleteBlocked
)
}
}
}
}
@@ -3,9 +3,6 @@ package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.material3.*
@@ -19,6 +16,7 @@ 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.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.core.domain.model.DictionarySource
@@ -66,29 +64,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 {
val text = toString()
val filtered = text
.take(2048)
.filter { char ->
!char.isISOControl() &&
char != '\u0000' &&
char.code !in 128..<160
}
if (filtered != text) {
replace(0, length, filtered)
}
}
@Composable
fun DictionaryUrlInputContent(
existingSources: List<DictionarySource>,
@@ -96,22 +71,11 @@ fun DictionaryUrlInputContent(
onCancel: () -> Unit,
modifier: Modifier = Modifier
) {
val urls = remember { mutableStateListOf(TextFieldState("")) }
val urls = remember { mutableStateListOf(TextFieldValue("")) }
val validationErrors = remember { mutableStateMapOf<Int, String>() }
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),
@@ -132,8 +96,8 @@ fun DictionaryUrlInputContent(
// Cache prefixes for all input URLs to avoid repeated extraction during recomposition
val inputUrlPrefixes = remember {
derivedStateOf {
urls.associate { urlState ->
val urlString = urlState.trimmedText()
urls.associate { urlValue ->
val urlString = urlValue.text.trim()
urlString to if (urlString.isNotEmpty()) {
DictionarySource.extractPrefix(urlString)
} else null
@@ -155,10 +119,10 @@ fun DictionaryUrlInputContent(
style = MaterialTheme.typography.headlineSmall
)
urls.forEachIndexed { index, urlState ->
urls.forEachIndexed { index, urlValue ->
val isLastItem = index == urls.lastIndex
val urlString = urlState.trimmedText()
val urlString = urlValue.text.trim()
val cachedPrefix = inputUrlPrefixes.value[urlString]
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
validateDictionaryUrl(
@@ -166,7 +130,7 @@ fun DictionaryUrlInputContent(
url = urlString,
existingSources = existingSources,
existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.trimmedText() },
urlsInForm = urls.map { it.text.trim() },
urlPrefix = cachedPrefix,
errorMessages = errorMessages
)
@@ -176,8 +140,20 @@ fun DictionaryUrlInputContent(
val displayError = validationErrors[index] ?: realtimeError
OutlinedTextField(
state = urlState,
inputTransformation = urlInputTransformation,
value = urlValue,
onValueChange = { newValue ->
val filtered = newValue.copy(
text = newValue.text
.take(2048)
.filter { char ->
!char.isISOControl() &&
char != '\u0000' &&
char.code !in 128..<160
}
)
urls[index] = filtered
validationErrors.remove(index)
},
label = { Text(if (index == 0) stringResource(R.string.dictionary_source_url_hint) else "URL ${index + 1}") },
isError = displayError != null,
supportingText = displayError?.let { { Text(it) } },
@@ -192,7 +168,7 @@ fun DictionaryUrlInputContent(
Modifier
}
),
lineLimits = TextFieldLineLimits.SingleLine,
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next
@@ -212,7 +188,7 @@ fun DictionaryUrlInputContent(
TextButton(
onClick = {
urls.add(TextFieldState(""))
urls.add(TextFieldValue(""))
},
modifier = Modifier
.align(Alignment.Start)
@@ -248,8 +224,8 @@ fun DictionaryUrlInputContent(
val validUrls = mutableListOf<String>()
val seenUrls = mutableSetOf<String>()
urls.forEachIndexed { index, urlState ->
val urlString = urlState.trimmedText()
urls.forEachIndexed { index, urlValue ->
val urlString = urlValue.text.trim()
if (urlString.isBlank()) {
if (urls.size == 1) {
validationErrors[index] = errorMessages.empty
@@ -287,9 +263,9 @@ fun DictionaryUrlInputContent(
.weight(1f)
.testTag("add_confirm_button")
.semantics { testTagsAsResourceId = true },
enabled = urls.any { it.trimmedText().isNotBlank() } &&
urls.none { urlState ->
val urlString = urlState.trimmedText()
enabled = urls.any { it.text.trim().isNotBlank() } &&
urls.none { urlValue ->
val urlString = urlValue.text.trim()
if (urlString.isEmpty()) {
false
} else {
@@ -299,7 +275,7 @@ fun DictionaryUrlInputContent(
url = urlString,
existingSources = existingSources,
existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.trimmedText() },
urlsInForm = urls.map { it.text.trim() },
urlPrefix = urlPrefix,
errorMessages = errorMessages
)
@@ -313,5 +289,3 @@ fun DictionaryUrlInputContent(
Spacer(modifier = Modifier.height(8.dp))
}
}
private fun TextFieldState.trimmedText(): String = text.trim().toString()
@@ -1,40 +0,0 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.core.domain.model.SearchBarPosition
@Composable
fun SearchBarPositionSection(
currentPosition: SearchBarPosition,
onPositionChanged: (SearchBarPosition) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
SearchBarPosition.entries.forEach { position ->
val isSelected = currentPosition == position
OutlinedChoiceButton(
text = when (position) {
SearchBarPosition.TOP -> stringResource(R.string.search_bar_position_top)
SearchBarPosition.BOTTOM -> stringResource(R.string.search_bar_position_bottom)
},
onClick = { onPositionChanged(position) },
modifier = Modifier
.weight(1f)
.testTag("search_bar_${position.name}"),
selected = isSelected
)
}
}
}
@@ -6,7 +6,7 @@ object Spacing {
// Semantic spacing constants for specific UI elements
val topBarPadding = 8.dp
val topBarHeight = 56.dp
val iconStart = 32.dp
val searchTextStart = 68.dp
val listItemVertical = 8.dp
val contentStart = 32.dp
}
@@ -24,20 +24,12 @@ 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
-9
View File
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M13,3A9,9 0 0,0 4,12H1L4.89,15.89L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,19.99 10.51,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3M12,8V13L16.28,15.54L17,14.33L13.5,12.25V8H12Z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M7,9H2V7h5v2zM7,12H2v2h5v-2zM20.59,19l-3.83,-3.83c-0.8,0.52 -1.74,0.83 -2.76,0.83 -2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5c0,1.02 -0.31,1.96 -0.83,2.75L22,17.59 20.59,19zM17,11c0,-1.65 -1.35,-3 -3,-3s-3,1.35 -3,3 1.35,3 3,3 3,-1.35 3,-3zM2,19h10v-2H2v2z" />
</vector>
+4 -9
View File
@@ -2,6 +2,7 @@
<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>
@@ -11,13 +12,8 @@
<string name="language_english">Английский</string>
<string name="language_russian">Русский</string>
<string name="language_system">Системный</string>
<string name="search_bar_position">Строка поиска</string>
<string name="search_bar_position_top">Наверху</string>
<string name="search_bar_position_bottom">Внизу</string>
<string name="clear_search">Очистить поиск</string>
<string name="recent_searches">История поиска</string>
<string name="clear_search_history">Очистить</string>
<string name="remove_search_history_entry">Удалить из истории</string>
<string name="article_no_selected">Статья не выбрана</string>
<string name="article_return_to_search">Вернуться к поиску</string>
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
<string name="error_loading_article">Ошибка загрузки: %1$s</string>
@@ -52,7 +48,6 @@
<string name="notification_download_error_title">Ошибка загрузки</string>
<string name="notification_download_error_text">Не удалось скачать словари</string>
<string name="notification_indexing_title">Индексация словарей</string>
<string name="notification_preparing_indexing_title">Подготовка словарей к индексации</string>
<string name="archive_failed_open_source_file">Не удалось открыть исходный файл</string>
<string name="archive_failed_create_output_file">Не удалось создать выходной файл</string>
<string name="archive_failed_delete_file">Не удалось удалить файл: %1$s</string>
@@ -81,8 +76,8 @@
<string name="dictionary_source_duplicate_in_form">Дублирующийся URL в форме</string>
<string name="dictionary_source_duplicate_prefix">Этот словарь уже существует (одинаковый префикс)</string>
<string name="dictionary_source_url_empty">URL не может быть пустым</string>
<string name="dictionary_source_invalid_protocol">Должен начинаться с https://</string>
<string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dsl.dz</string>
<string name="dictionary_source_invalid_protocol">Должен начинаться с http:// или https://</string>
<string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dz</string>
<string name="dictionary_source_too_long">URL слишком длинный (максимум 2048 символов)</string>
<string name="dictionary_source_invalid_url">Неверный формат URL</string>
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
+4 -9
View File
@@ -2,6 +2,7 @@
<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>
@@ -11,13 +12,8 @@
<string name="language_english">English</string>
<string name="language_russian">Russian</string>
<string name="language_system">System</string>
<string name="search_bar_position">Search bar</string>
<string name="search_bar_position_top">Top</string>
<string name="search_bar_position_bottom">Bottom</string>
<string name="clear_search">Clear search</string>
<string name="recent_searches">Search history</string>
<string name="clear_search_history">Clear</string>
<string name="remove_search_history_entry">Remove from history</string>
<string name="article_no_selected">No article selected</string>
<string name="article_return_to_search">Return to search</string>
<string name="error_checking_dictionaries">Failed to check dictionary status</string>
<string name="error_loading_article">Loading error: %1$s</string>
@@ -52,7 +48,6 @@
<string name="notification_download_error_title">Download error</string>
<string name="notification_download_error_text">Failed to download dictionaries</string>
<string name="notification_indexing_title">Indexing dictionaries</string>
<string name="notification_preparing_indexing_title">Preparing dictionaries for indexing</string>
<string name="archive_failed_open_source_file">Failed to open source file</string>
<string name="archive_failed_create_output_file">Failed to create output file</string>
<string name="archive_failed_delete_file">Failed to delete file: %1$s</string>
@@ -79,8 +74,8 @@
<string name="dictionary_source_duplicate_in_form">Duplicate URL in the form</string>
<string name="dictionary_source_duplicate_prefix">This dictionary already exists</string>
<string name="dictionary_source_url_empty">URL cannot be empty</string>
<string name="dictionary_source_invalid_protocol">Must start with https://</string>
<string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dsl.dz</string>
<string name="dictionary_source_invalid_protocol">Must start with http:// or https://</string>
<string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dz</string>
<string name="dictionary_source_too_long">URL is too long (max 2048 characters)</string>
<string name="dictionary_source_invalid_url">Invalid URL format</string>
<string name="dictionary_source_suspicious_characters">URL contains invalid characters</string>
@@ -1,9 +0,0 @@
# Strip android.util.Log warnings/errors from release builds.
# Debug builds keep full logging for diagnostics.
-assumenosideeffects class android.util.Log {
public static int w(java.lang.String, java.lang.String);
public static int w(java.lang.String, java.lang.String, java.lang.Throwable);
public static int w(java.lang.String, java.lang.Throwable);
public static int e(java.lang.String, java.lang.String);
public static int e(java.lang.String, java.lang.String, java.lang.Throwable);
}
@@ -1 +1 @@
- Initial release
- Initial F-Droid release
@@ -1,6 +0,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
@@ -1,16 +0,0 @@
- 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
- The article title can be copied, tapping the article clears the selection
- Source URLs accept host:port addresses
- Dictionary downloads retry automatically when the network switches
- Download progress notifications are more reliable
- Search results clear instantly when the query is erased
- Leftover files from cancelled downloads are now removed automatically
- 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 an Android dictionary reader for DSL-format dictionaries. Download, import, and search multiple dictionaries with instant article retrieval via DictZip random-access compression.
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.
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 two-pane layout for tablets and large screens
- Adaptive three-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: 229 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

@@ -1 +1 @@
- Первый релиз
- Первый релиз для F-Droid
@@ -1,6 +0,0 @@
- Добавлена история поиска
- Поисковую строку можно расположить сверху или снизу экрана
- Исправлено усечение заголовков со знаками ударения в результатах поиска
- Повышена стабильность загрузки, индексации и навигации
- Экран «О приложении» открывается быстрее
- Обновлён инструментарий сборки, проведена чистка кода
@@ -1,16 +0,0 @@
- Приложение запускается быстрее
- Ускорена индексация словарей
- Повышена стабильность приложения
- Плавные анимации переходов между экранами
- Статьи корректно отображают переносы строк, отступы и цвета из DSL-разметки
- Табуляции и лишние пробелы в статьях больше не ломают вёрстку
- Заголовок статьи можно скопировать, тап по статье сбрасывает выделение
- Адреса источников принимают формат host:port
- Загрузка словарей автоматически повторяется при переключении сети
- Уведомления о прогрессе загрузки стали надёжнее
- Результаты поиска очищаются мгновенно при стирании запроса
- Остаточные файлы после отменённых загрузок удаляются автоматически
- Длинные статьи со ссылками больше не подвисают при прокрутке
- Большие списки результатов поиска отображаются быстрее
- Тёмные цвета статей читаемы в тёмной теме
- Обновлены инструменты сборки и документация
@@ -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: 229 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

+4 -4
View File
@@ -1,12 +1,12 @@
[versions]
aboutlibraries = "15.2.0"
activity_compose = "1.13.0"
agp = "9.4.1"
compose_bom = "2026.09.00"
core = "1.19.1"
agp = "9.3.1"
compose_bom = "2026.08.00"
core = "1.19.0"
datastore_preferences = "1.2.1"
documentfile = "1.1.0"
kotlin = "2.4.20"
kotlin = "2.4.10"
lifecycle_runtime_ktx = "2.11.0"
okhttp = "5.5.0"
paging = "3.5.1"
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
Vendored
+2 -2
View File
@@ -20,7 +20,7 @@
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
Vendored
+2 -2
View File
@@ -19,7 +19,7 @@
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2
@rem Execute gradlew
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel