2 Commits
Author SHA1 Message Date
OneWay 05b80f21f4 fix(search): drop the open article when the query is cleared 2026-09-19 15:17:53 +08:00
OneWay 114eb78bc2 chore(deps): bump Compose BOM to 2026.09.00
Measure-pass defect in compose ui 1.12.0: layoutChildrenBlock clobbered
the isPlacingForAlignment flag of child nodes during alignment
placement, so sheet anchors were recalculated from a stale content size.

compose ui 1.12.1 preserves and restores child placement flags, so the
anchor is computed from a consistent content size on the first frame.
2026-09-19 15:17:15 +08:00
71 changed files with 585 additions and 1491 deletions

No files matched your search

+28 -19
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,7 +111,7 @@ 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
+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,13 @@ 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,11 +47,13 @@ class MainActivity : ComponentActivity() {
val app = application as ReSearchApplication
viewModelFactory {
initializer {
SearchViewModel(application, app.localDictionaryRepository, app.preferencesManager, createSavedStateHandle())
SearchViewModel(application, app.localDictionaryRepository, createSavedStateHandle())
}
}
}
private var initialSectionExpansion: SectionExpansionStates? = null
private val settingsViewModel: SettingsViewModel by viewModels {
val app = application as ReSearchApplication
val versionName = try {
@@ -69,8 +72,11 @@ class MainActivity : ComponentActivity() {
app.dictionaryImportManager,
app.dictionaryProgressStateHolder,
versionName,
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(),
app.initialLaunch.value?.searchBarPosition ?: SearchBarPosition.TOP,
initialSectionExpansion ?: SectionExpansionStates(
isThemeExpanded = false,
isLanguageExpanded = false,
isDictionariesExpanded = true,
),
)
}
}
@@ -81,16 +87,19 @@ 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)
initialSectionExpansion = loadInitialSectionExpansion(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 +115,7 @@ class MainActivity : ComponentActivity() {
AppNavigation(
searchViewModel = searchViewModel,
settingsViewModel = settingsViewModel,
seedNoDictionaries = launch.seedNoDictionaries
seedNoDictionaries = initialHadNoDictionaries
)
}
}
@@ -129,6 +138,69 @@ 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
}
}
private fun loadInitialSectionExpansion(app: ReSearchApplication): SectionExpansionStates {
return try {
runBlocking {
app.preferencesManager.sectionExpansionStates()
}
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to load section expansion states: ${e.message}")
SectionExpansionStates(
isThemeExpanded = false,
isLanguageExpanded = false,
isDictionariesExpanded = true,
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
@@ -144,7 +216,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,9 +9,7 @@ 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
@@ -20,10 +18,9 @@ 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,
val isThemeExpanded: Boolean,
val isLanguageExpanded: Boolean,
val isDictionariesExpanded: Boolean,
)
class PreferencesManager(private val context: Context) {
@@ -31,19 +28,12 @@ class PreferencesManager(private val context: Context) {
val THEME_MODE = stringPreferencesKey("theme_mode")
val DICTIONARY_SOURCES = stringPreferencesKey("dictionary_sources")
val LANGUAGE = stringPreferencesKey("language")
val SEARCH_BAR_POSITION = stringPreferencesKey("search_bar_position")
val IS_THEME_EXPANDED = booleanPreferencesKey("is_theme_expanded")
val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_expanded")
val IS_SEARCH_BAR_EXPANDED = booleanPreferencesKey("is_search_bar_expanded")
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
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 +57,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]
@@ -122,7 +101,6 @@ class PreferencesManager(private val context: Context) {
SectionExpansionStates(
isThemeExpanded = preferences[PreferencesKeys.IS_THEME_EXPANDED] ?: false,
isLanguageExpanded = preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false,
isSearchBarExpanded = preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] ?: false,
isDictionariesExpanded = preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true,
)
}
@@ -165,11 +143,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 +153,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 +199,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(
@@ -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 {
@@ -136,11 +126,11 @@ class SearchViewModel(
}
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)
}
}
@@ -169,27 +159,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 +281,6 @@ class SearchViewModel(
}
entry?.let {
currentArticle?.let(articleHistory::addLast)
recordSearchHistory(sanitizedWord)
loadArticle(it)
}
}.onError { message, exception ->
@@ -321,6 +289,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()) }
}
@@ -5,6 +5,7 @@ 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
@@ -20,6 +21,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
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
@@ -38,25 +41,24 @@ 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.LocalConfiguration
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
@@ -91,10 +93,12 @@ fun AboutScreen(
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
@@ -156,14 +160,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,6 +185,7 @@ private fun AboutHeader(versionName: String) {
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AboutLibraryRow(
modifier: Modifier = Modifier,
@@ -347,9 +351,7 @@ private fun LicenseDialog(
this.alpha = alpha
}
) {
val maxHeight = with(LocalDensity.current) {
LocalWindowInfo.current.containerSize.height.toDp()
} - 96.dp
val maxHeight = LocalConfiguration.current.screenHeightDp.dp - 96.dp
Surface(
shape = AlertDialogDefaults.shape,
color = AlertDialogDefaults.containerColor,
@@ -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,36 @@ 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,
contentPadding = PaddingValues(top = Spacing.listItemVertical),
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.contentStart + indentStep * block.indent
)
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
) {
ArticleLinkText(
block = block,
@@ -238,18 +169,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(
@@ -281,9 +207,7 @@ 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
@@ -292,14 +216,7 @@ fun ArticleTitleBar(
null -> ""
}
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,
@@ -309,45 +226,44 @@ fun ArticleTitleBar(
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)
}
.padding(
top = Spacing.listItemVertical,
bottom = Spacing.listItemVertical
),
verticalAlignment = if (compact) Alignment.Top else Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically
) {
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 }
.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
.padding(start = if (showBackButton) Spacing.searchTextStart - Spacing.iconStart - 24.dp else 16.dp)
.testTag("article_title")
.semantics { testTagsAsResourceId = true }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ArticleScreen(
articleState: ArticleState?,
@@ -355,31 +271,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)
)
}
}
@@ -70,6 +70,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
}
@@ -85,7 +90,7 @@ object DslAnnotatedParser {
val lazyRefs = mutableListOf<LazyRef>()
var accentNext = false
var currentIndent = 0
var lastAppended: Char? = null
var mClosedPendingBreak = false
val exampleStyle = SpanStyle(color = colorScheme.secondaryText)
val labelStyle = SpanStyle(color = colorScheme.labelText)
val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline)
@@ -103,40 +108,32 @@ 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
mClosedPendingBreak = false
}
fun appendChar(char: Char, hard: Boolean = false) {
if (char == ' ' && !hard) {
if (lastAppended == null || lastAppended == ' ') return
fun appendChar(char: Char) {
if (mClosedPendingBreak) {
mClosedPendingBreak = false
if (refStack.isEmpty() && !char.isWhitespace()) {
flushBlock()
}
}
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 {
if (tagName == "m" || tagName == "m0" || tagName == "m1") return 0
val margin = tagName.drop(1).toIntOrNull() ?: return 0
@@ -150,7 +147,7 @@ object DslAnnotatedParser {
when (val char = dsl[i]) {
'\\' -> {
if (i + 1 < length) {
appendChar(dsl[i + 1], hard = true)
appendChar(dsl[i + 1])
i += 2
continue
}
@@ -190,11 +187,10 @@ object DslAnnotatedParser {
} else if (tagName == "'") {
accentNext = false
}
pop()
if (tagName.startsWith("m")) {
flushBlock()
currentIndent = 0
mClosedPendingBreak = true
}
pop()
} else {
when (tagName) {
"'" -> {
@@ -209,12 +205,12 @@ object DslAnnotatedParser {
}
}
"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 (builder.length > 0) {
if (refStack.isEmpty()) flushBlock() else builder.append("\n")
}
currentIndent = marginIndent(tagName)
push(exampleStyle)
@@ -247,18 +243,6 @@ object DslAnnotatedParser {
appendChar(char)
i++
}
'\n' -> {
if (refStack.isEmpty()) {
flushBlock()
} else {
appendChar(char)
}
i++
}
'\t' -> {
appendText("\u2002")
i++
}
else -> {
appendChar(char)
@@ -411,35 +395,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,7 +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.main.components.MainTopBar
import com.example.research.ui.theme.Spacing
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -23,11 +21,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 +44,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(
@@ -110,22 +90,15 @@ fun AdaptiveMainScreen(
)
},
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,
onNavigateBack = onNavigateBackFromArticle,
compact = true,
selectionReset = titleSelectionReset.intValue,
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
@@ -138,21 +111,26 @@ fun AdaptiveMainScreen(
}
},
)
if (isSearchBarAtBottom) {
searchBar()
}
}
LaunchedEffect(articleState) {
val targetValue = if (articleState == null) {
ThreePaneScaffoldValue(
LaunchedEffect(articleState, isQueryEmpty, isLandscape) {
val targetValue = when {
isLandscape -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Expanded,
secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden,
)
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
@@ -22,7 +20,6 @@ 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 +37,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 +47,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 +79,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 +93,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 +121,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 +133,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(
@@ -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)
)
}
}
}
@@ -1,5 +1,6 @@
package com.example.research.ui.main.components
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@@ -29,11 +30,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,24 +41,25 @@ 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) {
SearchResultItem(
entry = entry,
resultIndex = index,
onArticleClick = onArticleClick,
showDictionaryName = showDictionaryName
)
results[index]?.let { entry ->
Box(
modifier = Modifier.animateItem(
fadeInSpec = if (index < 8) tween(150) else null,
placementSpec = null,
fadeOutSpec = null
)
) {
SearchResultItem(
entry = entry,
resultIndex = index,
onArticleClick = onArticleClick,
showDictionaryName = showDictionaryName
)
}
}
}
@@ -2,28 +2,22 @@ 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 +32,7 @@ enum class Screen {
Home, Settings, Article, About
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AppNavigation(
searchViewModel: SearchViewModel,
@@ -103,18 +98,13 @@ 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 = {
@@ -160,6 +150,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 +169,6 @@ 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)
},
)
}
}
}
}
@@ -21,6 +21,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 +46,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 +126,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 +240,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))
}
@@ -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
@@ -9,7 +9,6 @@ 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
@@ -47,13 +46,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,
)
@@ -67,7 +64,6 @@ class SettingsViewModel(
progressStateHolder: DictionaryProgressStateHolder,
appVersion: String,
initialSectionExpansion: SectionExpansionStates,
initialSearchBarPosition: SearchBarPosition,
) : AndroidViewModel(application) {
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
@@ -80,9 +76,7 @@ class SettingsViewModel(
appVersion = appVersion,
isThemeExpanded = initialSectionExpansion.isThemeExpanded,
isLanguageExpanded = initialSectionExpansion.isLanguageExpanded,
isSearchBarExpanded = initialSectionExpansion.isSearchBarExpanded,
isDictionariesExpanded = initialSectionExpansion.isDictionariesExpanded,
searchBarPosition = initialSearchBarPosition,
)
)
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
@@ -102,7 +96,6 @@ class SettingsViewModel(
private var cancelRefreshPending = false
private var errorStateHandled = false
private var successStateHandled = false
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
init {
@@ -140,6 +133,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 +153,47 @@ 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 = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
}
}
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 +242,7 @@ class SettingsViewModel(
refreshDictionaryStatus()
}
}
else -> {}
}
SettingsUiState(
@@ -257,7 +252,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 +260,6 @@ class SettingsViewModel(
!operationState.importState.isActive,
isThemeExpanded = operationState.isThemeExpanded,
isLanguageExpanded = operationState.isLanguageExpanded,
isSearchBarExpanded = operationState.isSearchBarExpanded,
isDictionariesExpanded = operationState.isDictionariesExpanded,
appVersion = _uiState.value.appVersion,
)
@@ -277,15 +270,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 +289,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 +300,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 +372,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 +529,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()
@@ -630,10 +589,7 @@ class SettingsViewModel(
"NeedsUpdate" -> DictionaryStatus.NeedsUpdate
else -> null
}
if (restoredStatus != null &&
savedStatusDate == DateUtils.getCurrentDateString() &&
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }
) {
if (restoredStatus != null && savedStatusDate == DateUtils.getCurrentDateString()) {
dictionaryStatus.value = restoredStatus
} else {
dictionaryStatus.value = DictionaryStatus.Checking
@@ -1,17 +1,10 @@
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
@@ -35,7 +28,7 @@ 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 +83,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 +115,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 +140,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 +168,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 +185,7 @@ fun DictionaryManagement(
isDeleteBlocked = isDeleteBlocked
)
if (!isInProgress) {
if (!isBlockingMutations) {
DictionaryActionButtons(
onAddSource = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -220,8 +210,7 @@ fun DictionaryManagement(
focusManager.clearFocus(force = true)
setShowUrlBottomSheet(false)
},
sheetState = sheetState,
contentWindowInsets = { WindowInsets.statusBars }
sheetState = sheetState
) {
DictionaryUrlInputContent(
existingSources = sources,
@@ -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,6 +6,8 @@ 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>
+3 -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,7 @@
<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_return_to_search">Вернуться к поиску</string>
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
<string name="error_loading_article">Ошибка загрузки: %1$s</string>
@@ -52,7 +47,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 +75,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>
+3 -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,7 @@
<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_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 +47,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 +73,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 +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 +1,7 @@
- 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 +1,7 @@
- Приложение запускается быстрее
- Ускорена индексация словарей
- Повышена стабильность приложения
- Плавные анимации переходов между экранами
- Статьи корректно отображают переносы строк, отступы и цвета из 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

+3 -3
View File
@@ -1,12 +1,12 @@
[versions]
aboutlibraries = "15.2.0"
activity_compose = "1.13.0"
agp = "9.4.1"
agp = "9.3.1"
compose_bom = "2026.09.00"
core = "1.19.1"
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