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

No files matched your search

-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 OneWay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+26 -11
View File
@@ -5,6 +5,7 @@
<p align="center"> <p align="center">
<a href="#screenshots">Screenshots</a> &bull; <a href="#screenshots">Screenshots</a> &bull;
<a href="#features">Features</a> &bull; <a href="#features">Features</a> &bull;
<a href="#architecture">Architecture</a> &bull;
<a href="#dictionary-pipeline">Pipeline</a> &bull; <a href="#dictionary-pipeline">Pipeline</a> &bull;
<a href="#build">Build</a> <a href="#build">Build</a>
</p> </p>
@@ -22,17 +23,31 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
- Fast headword search across multiple DSL dictionaries. - Fast headword search across multiple DSL dictionaries.
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files. - Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
- Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases. - Download dictionaries from configured URLs.
- Automatic extraction, DictZip compression, and indexing. - Automatic extraction, DictZip compression, and indexing.
- Faithful article rendering with DSL indentation, margins, and named colors.
- Offline search after dictionaries are indexed. - Offline search after dictionaries are indexed.
- Random-access article loading from DictZip dictionaries. - 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. - Background processing with progress shown in Settings and notifications.
- Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe. - Dictionary management with enable, disable, update, and delete actions.
- Material 3 interface with dynamic color, light, dark, and system themes. - Material 3 interface with dynamic color, light, dark, and system themes.
- English and Russian localization. - English and Russian localization.
- About screen with open source license information. - 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 ## Dictionary Pipeline
ReSearch supports both downloaded and locally imported dictionaries. ReSearch supports both downloaded and locally imported dictionaries.
@@ -79,16 +94,16 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack ## Tech Stack
- Kotlin 2.4.20 - Kotlin 2.4.10
- Jetpack Compose (BOM 2026.09.00) - Jetpack Compose (BOM 2026.08.00)
- Material 3 - Material 3
- Coroutines and Flow 1.11.0 - Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1 - DataStore Preferences 1.2.1
- Paging 3.5.1 - Paging 3.5.1
- OkHttp 5.5.0 - OkHttp 5.4.0
- kotlinx.serialization 1.11.0 - kotlinx.serialization 1.11.0
- AboutLibraries 15.2.0 metadata generation - AboutLibraries 15.0.4 metadata generation
- Android Gradle Plugin 9.4.1 - Android Gradle Plugin 9.3.1
## Build ## Build
@@ -96,8 +111,8 @@ The main search screen remains usable as long as at least one indexed dictionary
./gradlew :app:assembleRelease ./gradlew :app:assembleRelease
``` ```
The release APK targets `arm64-v8a`. The release APK targets `arm64-v8a`. English and Russian resources are packaged together.
## License ## License
[MIT](LICENSE) MIT
+2 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId = "com.example.research" applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt() minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt() targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 9 versionCode = 8
versionName = "1.6.0" versionName = "1.5.0"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -26,7 +26,6 @@ import com.example.research.common.ui.AppLocaleProvider
import com.example.research.common.ui.BackgroundTextMeasurementProvider import com.example.research.common.ui.BackgroundTextMeasurementProvider
import com.example.research.common.ui.theme.ReSearchTheme import com.example.research.common.ui.theme.ReSearchTheme
import com.example.research.core.domain.model.AppTheme import com.example.research.core.domain.model.AppTheme
import com.example.research.data.local.preferences.SectionExpansionStates
import com.example.research.feature.search.SearchAction import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel import com.example.research.feature.search.SearchViewModel
import com.example.research.ui.navigation.AppNavigation import com.example.research.ui.navigation.AppNavigation
@@ -52,8 +51,6 @@ class MainActivity : ComponentActivity() {
} }
} }
private var initialSectionExpansion: SectionExpansionStates? = null
private val settingsViewModel: SettingsViewModel by viewModels { private val settingsViewModel: SettingsViewModel by viewModels {
val app = application as ReSearchApplication val app = application as ReSearchApplication
val versionName = try { val versionName = try {
@@ -72,11 +69,6 @@ class MainActivity : ComponentActivity() {
app.dictionaryImportManager, app.dictionaryImportManager,
app.dictionaryProgressStateHolder, app.dictionaryProgressStateHolder,
versionName, versionName,
initialSectionExpansion ?: SectionExpansionStates(
isThemeExpanded = false,
isLanguageExpanded = false,
isDictionariesExpanded = true,
),
) )
} }
} }
@@ -92,7 +84,6 @@ class MainActivity : ComponentActivity() {
val initialTheme = loadInitialTheme(app) val initialTheme = loadInitialTheme(app)
val initialLanguage = loadInitialLanguage(app) val initialLanguage = loadInitialLanguage(app)
val initialHadNoDictionaries = loadInitialHadNoDictionaries(app) val initialHadNoDictionaries = loadInitialHadNoDictionaries(app)
initialSectionExpansion = loadInitialSectionExpansion(app)
handleIntent(intent) handleIntent(intent)
setContent { setContent {
@@ -186,21 +177,6 @@ class MainActivity : ComponentActivity() {
} }
} }
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) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleIntent(intent) handleIntent(intent)
@@ -109,7 +109,7 @@ object DictionaryProgressModel {
return Snapshot( return Snapshot(
progress = p, progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100), 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 { } else {
DOWNLOAD_WEIGHT + EXTRACT_WEIGHT + idx * INDEX_WEIGHT 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( return Snapshot(
progress = p, progress = p,
@@ -32,7 +32,6 @@ class DictionaryProgressPresenter(
fun beginFlow() { fun beginFlow() {
terminalActive = false terminalActive = false
notifications.cancelTerminalNotification()
} }
override fun showImportSuccess() { override fun showImportSuccess() {
@@ -6,6 +6,5 @@ interface NotificationPort {
fun showDownloadSuccess() fun showDownloadSuccess()
fun showError() fun showError()
fun cancelProgressNotification() fun cancelProgressNotification()
fun cancelTerminalNotification()
fun hasActiveProgressNotification(): Boolean fun hasActiveProgressNotification(): Boolean
} }
@@ -13,7 +13,6 @@ class NotificationHelper(private val context: Context) : NotificationPort {
companion object { companion object {
const val CHANNEL_ID = "download_progress_channel" const val CHANNEL_ID = "download_progress_channel"
const val NOTIFICATION_ID = 1 const val NOTIFICATION_ID = 1
const val TERMINAL_NOTIFICATION_ID = 2
} }
private val notificationManager = NotificationManagerCompat.from(context) private val notificationManager = NotificationManagerCompat.from(context)
private var lastNotificationKey: Int = 0 private var lastNotificationKey: Int = 0
@@ -92,7 +91,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent) .setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
safeNotifyTerminal(notification) safeNotify(notification)
} }
fun showErrorNotification() { fun showErrorNotification() {
if (!canShowNotification()) return if (!canShowNotification()) return
@@ -104,17 +103,13 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent) .setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
safeNotifyTerminal(notification) safeNotify(notification)
} }
fun cancelNotification() { fun cancelNotification() {
lastNotificationKey = 0 lastNotificationKey = 0
notificationManager.cancel(NOTIFICATION_ID) notificationManager.cancel(NOTIFICATION_ID)
} }
override fun cancelTerminalNotification() {
notificationManager.cancel(TERMINAL_NOTIFICATION_ID)
}
override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0 override fun hasActiveProgressNotification(): Boolean = lastNotificationKey != 0
fun showUnifiedProgressNotification( fun showUnifiedProgressNotification(
@@ -166,14 +161,6 @@ class NotificationHelper(private val context: Context) : NotificationPort {
// Notification permission denied or security policy blocked // 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() { fun showImportSuccessNotification() {
if (!canShowNotification()) return if (!canShowNotification()) return
lastNotificationKey = 0 lastNotificationKey = 0
@@ -184,7 +171,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
.setContentIntent(mainActivityPendingIntent) .setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
safeNotifyTerminal(notification) safeNotify(notification)
} }
fun createForegroundNotification(): Notification { fun createForegroundNotification(): Notification {
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -102,15 +102,12 @@ data class DictionarySource(
fun validateTemplate(template: String): ValidationResult { fun validateTemplate(template: String): ValidationResult {
val trimmed = template.trim() val trimmed = template.trim()
val lowerTemplate = trimmed.lowercase()
return when { return when {
trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY) trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY)
trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG) trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG)
!trimmed.startsWith("http://") && !trimmed.startsWith("https://") -> !trimmed.startsWith("http://") && !trimmed.startsWith("https://") ->
ValidationResult.Error(ValidationError.INVALID_PROTOCOL) ValidationResult.Error(ValidationError.INVALID_PROTOCOL)
!lowerTemplate.endsWith(".gz") && !trimmed.endsWith(".gz") && !trimmed.endsWith(".dsl") && !trimmed.endsWith(".dz") ->
!lowerTemplate.endsWith(".dsl") &&
!lowerTemplate.endsWith(".dsl.dz") ->
ValidationResult.Error(ValidationError.INVALID_EXTENSION) ValidationResult.Error(ValidationError.INVALID_EXTENSION)
!isValidUrlStructure(trimmed) -> !isValidUrlStructure(trimmed) ->
ValidationResult.Error(ValidationError.INVALID_URL_STRUCTURE) ValidationResult.Error(ValidationError.INVALID_URL_STRUCTURE)
@@ -135,15 +132,8 @@ data class DictionarySource(
val parts = afterProtocol.split("/") val parts = afterProtocol.split("/")
if (parts.size < 2) return false if (parts.size < 2) return false
val hostWithPort = parts[0] val domain = parts[0]
if (hostWithPort.isEmpty()) return false if (domain.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
if (domain != "localhost" && !domain.contains(".")) return false if (domain != "localhost" && !domain.contains(".")) return false
val filename = parts.last() val filename = parts.last()
@@ -10,19 +10,12 @@ import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
data class SectionExpansionStates(
val isThemeExpanded: Boolean,
val isLanguageExpanded: Boolean,
val isDictionariesExpanded: Boolean,
)
class PreferencesManager(private val context: Context) { class PreferencesManager(private val context: Context) {
private object PreferencesKeys { private object PreferencesKeys {
val THEME_MODE = stringPreferencesKey("theme_mode") val THEME_MODE = stringPreferencesKey("theme_mode")
@@ -33,7 +26,6 @@ class PreferencesManager(private val context: Context) {
val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded") val IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths") val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries") val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
val DICTIONARY_STATUS = stringPreferencesKey("dictionary_status")
} }
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -87,24 +79,6 @@ class PreferencesManager(private val context: Context) {
} }
} }
suspend fun dictionaryStatus(): String? =
context.dataStore.data.first()[PreferencesKeys.DICTIONARY_STATUS]
suspend fun saveDictionaryStatus(status: String) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.DICTIONARY_STATUS] = status
}
}
suspend fun sectionExpansionStates(): SectionExpansionStates =
context.dataStore.data.first().let { preferences ->
SectionExpansionStates(
isThemeExpanded = preferences[PreferencesKeys.IS_THEME_EXPANDED] ?: false,
isLanguageExpanded = preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false,
isDictionariesExpanded = preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true,
)
}
suspend fun setDictionaryActive(path: String, isActive: Boolean) { suspend fun setDictionaryActive(path: String, isActive: Boolean) {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS] val disabledPaths = preferences[PreferencesKeys.DISABLED_DICTIONARY_PATHS]
@@ -180,19 +180,14 @@ internal fun parseDslArticleContent(rawContent: String): String {
while (i < len) { while (i < len) {
val lineStart = i val lineStart = i
while (i < len && rawContent[i] != '\n' && rawContent[i] != '\r') i++ while (i < len && rawContent[i] != '\n') i++
val lineEnd = i val lineEnd = i
if (i < len && rawContent[i] == '\r') {
i++
if (i < len && rawContent[i] == '\n') i++ if (i < len && rawContent[i] == '\n') i++
} else if (i < len && rawContent[i] == '\n') {
i++
}
if (lineStart == lineEnd && i >= len) break if (lineStart == lineEnd && i >= len) break
val line = rawContent.substring(lineStart, lineEnd) val line = rawContent.substring(lineStart, lineEnd)
val trimmed = line.trim() val trimmed = line.trimEnd()
if (trimmed.isEmpty()) continue if (trimmed.isEmpty()) continue
if (skippingHeadwords) { if (skippingHeadwords) {
@@ -182,7 +182,7 @@ class LocalDictionaryRepository(
progressTracker = ProgressTracker(filesToScan) progressTracker = ProgressTracker(filesToScan)
existingDictionaries.forEach { dict -> existingDictionaries.forEach { dict ->
progressTracker?.updateFileProgress(File(dict.path).name, 1.0f) progressTracker?.updateFileProgress(dict.name, 1.0f)
} }
mutableIndexingProgress.emit( mutableIndexingProgress.emit(
@@ -64,14 +64,7 @@ class DictionaryChecker(
val previousDate = DateUtils.getPreviousDateString() val previousDate = DateUtils.getPreviousDateString()
val dictFiles = getDictFiles(folderUri, files) val dictFiles = getDictFiles(folderUri, files)
val datedSources = enabledSources.filter { source -> val allHaveToday = enabledSources.all { source ->
DictionarySource.hasDatePlaceholder(source.urlTemplate)
}
if (datedSources.isEmpty()) {
return@withContext true
}
val allHaveToday = datedSources.all { source ->
dictFiles.hasFileForSource(source, currentDate) dictFiles.hasFileForSource(source, currentDate)
} }
@@ -79,28 +72,19 @@ class DictionaryChecker(
return@withContext true return@withContext true
} }
val allHaveYesterday = datedSources.all { source -> val allHaveYesterday = enabledSources.all { source ->
dictFiles.hasFileForSource(source, previousDate) dictFiles.hasFileForSource(source, previousDate)
} }
if (allHaveYesterday) { if (allHaveYesterday) {
val serverHasToday = datedSources.any { source -> val serverHasToday = enabledSources.any { source ->
isSourceAvailableOnServer(source, currentDate) isSourceAvailableOnServer(source, currentDate)
} }
return@withContext !serverHasToday return@withContext !serverHasToday
} }
val serverCanOfferNewer = datedSources.any { source -> false
when (probeSource(source, currentDate)) {
SourceAvailability.AVAILABLE -> true
SourceAvailability.NOT_FOUND ->
probeSource(source, previousDate) == SourceAvailability.AVAILABLE
SourceAvailability.UNKNOWN -> false
}
}
return@withContext !serverCanOfferNewer
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking currency: ${e.message}", e) Log.e(TAG, "Error checking currency: ${e.message}", e)
false false
@@ -88,12 +88,6 @@ class DictionaryForegroundService : Service() {
stopForTimeout(startId) stopForTimeout(startId)
} }
override fun onTaskRemoved(rootIntent: Intent?) {
cancelAllWork()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onTimeout(startId: Int, fgsType: Int) { override fun onTimeout(startId: Int, fgsType: Int) {
stopForTimeout(startId) stopForTimeout(startId)
} }
@@ -131,7 +125,7 @@ class DictionaryForegroundService : Service() {
startForegroundService() startForegroundService()
} }
ACTION_FINISH -> { ACTION_FINISH -> {
stopForegroundService(removeNotification = true) stopForegroundService(removeNotification = false)
} }
ACTION_STOP -> { ACTION_STOP -> {
try { try {
@@ -122,7 +122,6 @@ class SearchViewModel(
SearchAction.ClearQuery -> { SearchAction.ClearQuery -> {
mutableSearchQuery.value = "" mutableSearchQuery.value = ""
savedStateHandle[KEY_SEARCH_QUERY] = "" savedStateHandle[KEY_SEARCH_QUERY] = ""
clearArticle()
} }
is SearchAction.ArticleClicked -> { is SearchAction.ArticleClicked -> {
articleHistory.clear() articleHistory.clear()
@@ -137,9 +136,6 @@ class SearchViewModel(
fun onSearchQueryChanged(query: String) { fun onSearchQueryChanged(query: String) {
val sanitized = query.sanitizeQuery().trim() val sanitized = query.sanitizeQuery().trim()
mutableSearchQuery.value = sanitized mutableSearchQuery.value = sanitized
if (sanitized.isBlank()) {
clearArticle()
}
// Debounce SavedStateHandle writes to reduce disk I/O // Debounce SavedStateHandle writes to reduce disk I/O
debounceSaveQueryJob?.cancel() debounceSaveQueryJob?.cancel()
debounceSaveQueryJob = viewModelScope.launch { debounceSaveQueryJob = viewModelScope.launch {
@@ -1,25 +1,18 @@
package com.example.research.ui.about package com.example.research.ui.about
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -31,17 +24,13 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@@ -55,7 +44,6 @@ import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.ui.theme.AppWindowInsets import com.example.research.ui.theme.AppWindowInsets
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@@ -78,15 +66,6 @@ fun AboutScreen(
} }
var expandedLibraryId by remember { mutableStateOf<String?>(null) } var expandedLibraryId by remember { mutableStateOf<String?>(null) }
var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) } var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) }
val onToggleLibrary = remember {
{ uniqueId: String ->
expandedLibraryId = if (expandedLibraryId == uniqueId) {
null
} else {
uniqueId
}
}
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -114,20 +93,24 @@ fun AboutScreen(
.semantics { testTagsAsResourceId = true } .semantics { testTagsAsResourceId = true }
.padding(padding) .padding(padding)
) { ) {
item(key = "about_header", contentType = "about_header") { item(key = "about_header") {
AboutHeader(versionName = versionName) AboutHeader(versionName = versionName)
} }
items( items(
items = librariesData?.libraries.orEmpty(), items = librariesData?.libraries.orEmpty(),
key = AboutLibrary::uniqueId, key = AboutLibrary::uniqueId,
contentType = { "about_library" },
) { library -> ) { library ->
AboutLibraryRow( AboutLibraryRow(
modifier = Modifier.animateItem(fadeInSpec = tween(150)),
library = library, library = library,
licenses = librariesData?.licenses.orEmpty(), licenses = librariesData?.licenses.orEmpty(),
expanded = expandedLibraryId == library.uniqueId, expanded = expandedLibraryId == library.uniqueId,
onToggle = onToggleLibrary, onToggle = {
expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
null
} else {
library.uniqueId
}
},
onLicenseClick = { dialogLicense = it }, onLicenseClick = { dialogLicense = it },
) )
} }
@@ -188,33 +171,27 @@ private fun AboutHeader(versionName: String) {
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun AboutLibraryRow( private fun AboutLibraryRow(
modifier: Modifier = Modifier,
library: AboutLibrary, library: AboutLibrary,
licenses: Map<String, AboutLicense>, licenses: Map<String, AboutLicense>,
expanded: Boolean, expanded: Boolean,
onToggle: (String) -> Unit, onToggle: () -> Unit,
onLicenseClick: (AboutLicense) -> Unit, onLicenseClick: (AboutLicense) -> Unit,
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val website = library.website?.takeIf(String::isNotBlank) val website = library.website?.takeIf(String::isNotBlank)
val source = library.scm?.url?.takeIf(String::isNotBlank) val source = library.scm?.url?.takeIf(String::isNotBlank)
val background by animateColorAsState( Surface(
targetValue = if (expanded) { modifier = Modifier
.fillMaxWidth()
.testTag("about_library_row")
.semantics { testTagsAsResourceId = true },
color = if (expanded) {
MaterialTheme.colorScheme.surfaceContainerHighest MaterialTheme.colorScheme.surfaceContainerHighest
} else { } else {
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
}, },
label = "aboutLibraryRowBackground", onClick = onToggle
)
Surface(
modifier = modifier
.fillMaxWidth()
.testTag("about_library_row")
.semantics { testTagsAsResourceId = true },
color = background,
onClick = { onToggle(library.uniqueId) }
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -314,79 +291,32 @@ private fun AboutLibraryRow(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun LicenseDialog( private fun LicenseDialog(
license: AboutLicense, license: AboutLicense,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onOpenWebsite: (String) -> Unit, onOpenWebsite: (String) -> Unit,
) { ) {
var open by remember { mutableStateOf(false) } AlertDialog(
var wasOpen by remember { mutableStateOf(false) } onDismissRequest = onDismiss,
LaunchedEffect(Unit) { open = true } title = { Text(license.name) },
val scale by animateFloatAsState( text = {
targetValue = if (open) 1f else 0.92f,
animationSpec = tween(180),
label = "licenseDialogScale",
)
val alpha by animateFloatAsState(
targetValue = if (open) 1f else 0f,
animationSpec = tween(180),
label = "licenseDialogAlpha",
)
LaunchedEffect(open) {
if (open) {
wasOpen = true
} else if (wasOpen) {
snapshotFlow { alpha }.first { it == 0f }
onDismiss()
}
}
BasicAlertDialog(
onDismissRequest = { open = false },
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
}
) {
val maxHeight = LocalConfiguration.current.screenHeightDp.dp - 96.dp
Surface(
shape = AlertDialogDefaults.shape,
color = AlertDialogDefaults.containerColor,
tonalElevation = AlertDialogDefaults.TonalElevation,
) {
Column(modifier = Modifier.padding(24.dp).heightIn(max = maxHeight)) {
Text(
text = license.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = license.content ?: stringResource(R.string.license_text_unavailable), text = license.content ?: stringResource(R.string.license_text_unavailable),
style = MaterialTheme.typography.bodyMedium, modifier = Modifier.verticalScroll(rememberScrollState())
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) )
Spacer(modifier = Modifier.height(24.dp)) },
Row( confirmButton = {
modifier = Modifier.fillMaxWidth(), TextButton(onClick = onDismiss) {
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) Text(stringResource(R.string.action_close))
) { }
},
dismissButton = {
license.url?.takeIf(String::isNotBlank)?.let { url -> license.url?.takeIf(String::isNotBlank)?.let { url ->
TextButton(onClick = { onOpenWebsite(url) }) { TextButton(onClick = { onOpenWebsite(url) }) {
Text(stringResource(R.string.website)) Text(stringResource(R.string.website))
} }
} }
TextButton(onClick = { open = false }) {
Text(stringResource(R.string.action_close))
}
}
}
}
} }
)
} }
@@ -1,7 +1,6 @@
package com.example.research.ui.article package com.example.research.ui.article
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -12,7 +11,6 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -20,9 +18,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -53,54 +48,6 @@ fun ArticleRoute(
) )
} }
private const val MERGED_BLOCK_MAX_CHARS = 1500
private const val MERGED_BLOCK_MAX_ITEMS = 12
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
}
@Composable @Composable
fun ArticleContent( fun ArticleContent(
articleState: ArticleState?, articleState: ArticleState?,
@@ -116,11 +63,7 @@ fun ArticleContent(
is ArticleState.Success -> { is ArticleState.Success -> {
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}" val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
val baseTextStyle = MaterialTheme.typography.bodyLarge.copy( val baseTextStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Proportional,
trim = LineHeightStyle.Trim.Both,
),
) )
val indentStep = 16.dp val indentStep = 16.dp
@@ -180,42 +123,35 @@ fun ArticleContent(
val isHeavyArticle = val isHeavyArticle =
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT 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))
}
} else {
merged
}
}
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
LazyColumn( LazyColumn(
state = listState, state = listState,
contentPadding = PaddingValues(top = Spacing.listItemVertical), modifier = modifier
modifier = listModifier .fillMaxSize()
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
.testTag("article_content") .testTag("article_content")
.semantics { .semantics {
testTagsAsResourceId = true testTagsAsResourceId = true
} }
) { ) {
itemsIndexed( itemsIndexed(
items = displayBlocks, items = articleState.dslContent,
key = { index, _ -> key = { index, _ ->
// Use stable key combining article save key and index
"$articleSaveKey:$index" "$articleSaveKey:$index"
}, },
contentType = { _, _ -> "article_block" } contentType = { _, _ -> "article_block" }
) { index, block -> ) { index, block ->
val followedByContinuation =
articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true
val textContent = @Composable {
Box( Box(
modifier = Modifier modifier = Modifier
.padding(start = Spacing.contentStart + indentStep * block.indent) .padding(
start = Spacing.iconStart + indentStep * block.indent
)
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
) { ) {
ArticleLinkText( ArticleLinkText(
block = block, block = block,
@@ -232,17 +168,12 @@ fun ArticleContent(
) )
} }
} }
}
}
val articleListModifier = modifier
.fillMaxSize()
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
if (isHeavyArticle) { if (isHeavyArticle) {
articleList(articleListModifier) textContent()
} else { } else {
SelectionContainer { articleList(articleListModifier) } SelectionContainer { textContent() }
}
}
} }
} }
is ArticleState.Error -> { is ArticleState.Error -> {
@@ -265,7 +196,16 @@ fun ArticleContent(
} }
} }
null -> { null -> {
Box(modifier = modifier.fillMaxSize()) Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.article_no_selected),
style = MaterialTheme.typography.bodyLarge
)
}
} }
} }
} }
@@ -275,15 +215,13 @@ fun ArticleTitleBar(
articleState: ArticleState?, articleState: ArticleState?,
showBackButton: Boolean, showBackButton: Boolean,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier
compact: Boolean = false,
selectionReset: Int = 0,
) { ) {
val title = when (articleState) { val title = when (articleState) {
is ArticleState.Loading -> articleState.title is ArticleState.Loading -> articleState.title
is ArticleState.Success -> articleState.title is ArticleState.Success -> articleState.title
is ArticleState.Error -> articleState.title is ArticleState.Error -> articleState.title
null -> "" null -> stringResource(R.string.article_no_selected)
} }
val backIcon = painterResource(R.drawable.ic_arrow_back) val backIcon = painterResource(R.drawable.ic_arrow_back)
@@ -292,24 +230,20 @@ fun ArticleTitleBar(
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
Row( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing) .windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
.then( .padding(
if (compact) { top = Spacing.topBarPadding,
Modifier.padding(vertical = Spacing.listItemVertical) bottom = Spacing.topBarPadding
} else { )
Modifier
.padding(top = Spacing.topBarPadding, bottom = Spacing.topBarPadding)
.height(Spacing.topBarHeight) .height(Spacing.topBarHeight)
}
),
verticalAlignment = Alignment.CenterVertically
) { ) {
if (showBackButton) { if (showBackButton) {
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = Spacing.iconStart) .padding(start = Spacing.iconStart)
.size(24.dp) .size(24.dp)
.clickable(onClick = onNavigateBack), .clickable(onClick = onNavigateBack),
@@ -323,8 +257,6 @@ fun ArticleTitleBar(
} }
} }
key(selectionReset) {
SelectionContainer {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.dictionaryTitleLarge, style = MaterialTheme.typography.dictionaryTitleLarge,
@@ -332,14 +264,13 @@ fun ArticleTitleBar(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.padding(start = if (showBackButton) Spacing.searchTextStart - Spacing.iconStart - 24.dp else 16.dp) .align(Alignment.CenterStart)
.padding(start = if (showBackButton) Spacing.searchTextStart else 16.dp)
.testTag("article_title") .testTag("article_title")
.semantics { testTagsAsResourceId = true } .semantics { testTagsAsResourceId = true }
) )
} }
} }
}
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -350,20 +281,12 @@ internal fun ArticleScreen(
onRefClick: ((String) -> Unit)? = null, onRefClick: ((String) -> Unit)? = null,
onScrollPositionChanged: ((Int, Int) -> Unit)? = null, onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
) { ) {
val titleSelectionReset = remember { mutableIntStateOf(0) }
Box(
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { titleSelectionReset.intValue++ }
) {
Scaffold( Scaffold(
topBar = { topBar = {
ArticleTitleBar( ArticleTitleBar(
articleState = articleState, articleState = articleState,
showBackButton = true, showBackButton = true,
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack
selectionReset = titleSelectionReset.intValue
) )
}, },
contentWindowInsets = AppWindowInsets.contentWithNavigation contentWindowInsets = AppWindowInsets.contentWithNavigation
@@ -376,5 +299,4 @@ internal fun ArticleScreen(
modifier = Modifier.padding(padding) modifier = Modifier.padding(padding)
) )
} }
}
} }
@@ -24,40 +24,6 @@ object DslAnnotatedParser {
"ref", "ex", "e", "trn", "com", "lang", "sup", "'" "ref", "ex", "e", "trn", "com", "lang", "sup", "'"
) )
private val DSL_NAMED_COLORS = mapOf(
"black" to Color(0xFF000000),
"silver" to Color(0xFFC0C0C0),
"gray" to Color(0xFF808080),
"grey" to Color(0xFF808080),
"lightgray" to Color(0xFFD3D3D3),
"lightgrey" to Color(0xFFD3D3D3),
"darkgray" to Color(0xFFA9A9A9),
"darkgrey" to Color(0xFFA9A9A9),
"white" to Color(0xFFFFFFFF),
"maroon" to Color(0xFF800000),
"red" to Color(0xFFFF0000),
"darkred" to Color(0xFF8B0000),
"crimson" to Color(0xFFDC143C),
"brown" to Color(0xFFA52A2A),
"orange" to Color(0xFFFFA500),
"pink" to Color(0xFFFFC0CB),
"purple" to Color(0xFF800080),
"violet" to Color(0xFFEE82EE),
"fuchsia" to Color(0xFFFF00FF),
"magenta" to Color(0xFFFF00FF),
"olive" to Color(0xFF808000),
"yellow" to Color(0xFFFFFF00),
"green" to Color(0xFF008000),
"darkgreen" to Color(0xFF006400),
"lime" to Color(0xFF00FF00),
"teal" to Color(0xFF008080),
"aqua" to Color(0xFF00FFFF),
"cyan" to Color(0xFF00FFFF),
"navy" to Color(0xFF000080),
"blue" to Color(0xFF0000FF),
"darkblue" to Color(0xFF00008B),
)
data class ColorScheme( data class ColorScheme(
val secondaryText: Color, val secondaryText: Color,
val labelText: Color, val labelText: Color,
@@ -70,6 +36,11 @@ object DslAnnotatedParser {
val endIndex: Int 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) { private val refCache = object : androidx.collection.LruCache<Pair<DslBlock, Int>, AnnotatedString>(32) {
override fun sizeOf(key: Pair<DslBlock, Int>, value: AnnotatedString): Int = 1 override fun sizeOf(key: Pair<DslBlock, Int>, value: AnnotatedString): Int = 1
} }
@@ -83,9 +54,9 @@ object DslAnnotatedParser {
val stack = ArrayDeque<SpanStyle>() val stack = ArrayDeque<SpanStyle>()
val refStack = ArrayDeque<Pair<Int, StringBuilder>>() val refStack = ArrayDeque<Pair<Int, StringBuilder>>()
val lazyRefs = mutableListOf<LazyRef>() val lazyRefs = mutableListOf<LazyRef>()
var lastChar: Char? = null
var accentNext = false var accentNext = false
var currentIndent = 0 var currentIndent = 0
var lastAppended: Char? = null
val exampleStyle = SpanStyle(color = colorScheme.secondaryText) val exampleStyle = SpanStyle(color = colorScheme.secondaryText)
val labelStyle = SpanStyle(color = colorScheme.labelText) val labelStyle = SpanStyle(color = colorScheme.labelText)
val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline) val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline)
@@ -103,38 +74,16 @@ object DslAnnotatedParser {
} }
fun flushBlock() { fun flushBlock() {
val text = builder.toAnnotatedString() if (builder.length > 0) {
if (text.any { !it.isWhitespace() }) {
result.add(DslBlock( result.add(DslBlock(
text = text, text = builder.toAnnotatedString(),
indent = currentIndent, indent = currentIndent,
lazyRefs = lazyRefs.toList() lazyRefs = lazyRefs.toList()
)) ))
}
builder = AnnotatedString.Builder() builder = AnnotatedString.Builder()
stack.forEach { builder.pushStyle(it) } stack.forEach { builder.pushStyle(it) }
lazyRefs.clear() lazyRefs.clear()
lastAppended = null
} }
fun appendChar(char: Char, hard: Boolean = false) {
if (char == ' ' && !hard) {
if (lastAppended == null || lastAppended == ' ') return
}
lastAppended = char
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
}
fun appendText(value: String) {
if (value.isEmpty()) return
builder.append(value)
if (refStack.isNotEmpty()) {
refStack.last().second.append(value)
}
lastAppended = value.last()
} }
fun marginIndent(tagName: String): Int { fun marginIndent(tagName: String): Int {
@@ -150,11 +99,20 @@ object DslAnnotatedParser {
when (val char = dsl[i]) { when (val char = dsl[i]) {
'\\' -> { '\\' -> {
if (i + 1 < length) { if (i + 1 < length) {
appendChar(dsl[i + 1], hard = true) val next = dsl[i + 1]
builder.append(next)
if (refStack.isNotEmpty()) {
refStack.last().second.append(next)
}
lastChar = next
i += 2 i += 2
continue continue
} }
appendChar(char) builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
lastChar = char
i++ i++
} }
'[' -> { '[' -> {
@@ -191,10 +149,6 @@ object DslAnnotatedParser {
accentNext = false accentNext = false
} }
pop() pop()
if (tagName.startsWith("m")) {
flushBlock()
currentIndent = 0
}
} else { } else {
when (tagName) { when (tagName) {
"'" -> { "'" -> {
@@ -207,15 +161,17 @@ object DslAnnotatedParser {
} else { } else {
builder.append("\n") builder.append("\n")
} }
lastChar = '\n'
} }
"p" -> { "p" -> {
push(labelStyle.copy(fontStyle = FontStyle.Italic)) push(labelStyle)
} }
"b" -> push(SpanStyle(fontWeight = FontWeight.Bold)) "b" -> push(SpanStyle(fontWeight = FontWeight.Bold))
"m", "m0", "m1", "m2", "m3", "m4", "m5" -> { "m", "m0", "m1", "m2", "m3", "m4", "m5" -> {
if (lastAppended != null && !lastAppended!!.isWhitespace()) { if (lastChar != '\n' && lastChar != null) {
flushBlock() if (refStack.isEmpty()) flushBlock() else builder.append("\n")
} }
lastChar = '\n'
currentIndent = marginIndent(tagName) currentIndent = marginIndent(tagName)
push(exampleStyle) push(exampleStyle)
} }
@@ -225,18 +181,7 @@ object DslAnnotatedParser {
refStack.addLast(builder.length to StringBuilder()) refStack.addLast(builder.length to StringBuilder())
} }
"ex", "e" -> push(exampleStyle) "ex", "e" -> push(exampleStyle)
"c" -> { "c", "t", "trn", "lang", "sup" -> push(SpanStyle())
val colorName = if (spaceIdx >= 0) {
tagWithParams.substring(spaceIdx + 1).trim().lowercase()
} else {
null
}
push(when {
colorName == null -> labelStyle
else -> DSL_NAMED_COLORS[colorName]?.let { SpanStyle(color = it) } ?: SpanStyle()
})
}
"t", "trn", "lang", "sup" -> push(SpanStyle())
"com" -> push(SpanStyle(fontStyle = FontStyle.Italic, color = colorScheme.labelText)) "com" -> push(SpanStyle(fontStyle = FontStyle.Italic, color = colorScheme.labelText))
} }
} }
@@ -244,23 +189,18 @@ object DslAnnotatedParser {
continue continue
} }
} }
appendChar(char) builder.append(char)
i++ if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
} }
'\n' -> { lastChar = char
if (refStack.isEmpty()) {
flushBlock()
} else {
appendChar(char)
}
i++
}
'\t' -> {
appendText("\u2002")
i++ i++
} }
else -> { else -> {
appendChar(char) builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
if (accentNext) { if (accentNext) {
builder.append('\u0301') builder.append('\u0301')
@@ -270,6 +210,7 @@ object DslAnnotatedParser {
accentNext = false accentNext = false
} }
lastChar = char
i++ i++
} }
} }
@@ -411,35 +352,6 @@ object DslAnnotatedParser {
linkText = Color(0xFF1E88E5), 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) // Allow external cache clearing (e.g., when dictionaries change)
fun clearRefCache() { fun clearRefCache() {
refCache.evictAll() refCache.evictAll()
@@ -1,7 +1,5 @@
package com.example.research.ui.main 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.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -15,7 +13,6 @@ import com.example.research.ui.article.ArticleContent
import com.example.research.ui.article.ArticleTitleBar import com.example.research.ui.article.ArticleTitleBar
import com.example.research.ui.article.ArticleState import com.example.research.ui.article.ArticleState
import com.example.research.ui.main.components.MainTopBar import com.example.research.ui.main.components.MainTopBar
import com.example.research.ui.theme.Spacing
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
@@ -87,27 +84,20 @@ fun AdaptiveMainScreen(
.windowInsetsPadding( .windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
) )
.padding(start = Spacing.contentStart, end = listEndPadding), .padding(start = 32.dp, end = listEndPadding),
listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars), listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
) )
}, },
detailPane = { detailPane = {
val titleSelectionReset = remember { mutableIntStateOf(0) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { titleSelectionReset.intValue++ }
.padding(bottom = 16.dp) .padding(bottom = 16.dp)
) { ) {
ArticleTitleBar( ArticleTitleBar(
articleState = articleState, articleState = articleState,
showBackButton = isShowingDetail && articleState != null, showBackButton = isShowingDetail,
onNavigateBack = onNavigateBackFromArticle, onNavigateBack = onNavigateBackFromArticle,
compact = true,
selectionReset = titleSelectionReset.intValue,
modifier = Modifier.windowInsetsPadding( modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top) WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
) )
@@ -122,13 +112,8 @@ fun AdaptiveMainScreen(
) )
} }
LaunchedEffect(articleState, isQueryEmpty, isLandscape) { LaunchedEffect(articleState, isQueryEmpty) {
val targetValue = when { val targetValue = when {
isLandscape -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Expanded,
secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden,
)
articleState == null -> ThreePaneScaffoldValue( articleState == null -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Hidden, primary = PaneAdaptedValue.Hidden,
secondary = PaneAdaptedValue.Expanded, secondary = PaneAdaptedValue.Expanded,
@@ -14,7 +14,6 @@ import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.research.ui.theme.AppWindowInsets import com.example.research.ui.theme.AppWindowInsets
import com.example.research.ui.theme.Spacing
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -170,7 +169,7 @@ internal fun MainScreen(
.padding(paddingValues) .padding(paddingValues)
.consumeWindowInsets(paddingValues) .consumeWindowInsets(paddingValues)
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing) .windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = Spacing.contentStart, end = 16.dp), .padding(start = 32.dp, end = 16.dp),
) )
} }
} }
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -51,7 +50,6 @@ internal fun SearchResultsPane(
var restoredScrollPosition by remember { mutableStateOf(false) } var restoredScrollPosition by remember { mutableStateOf(false) }
var pendingScrollToTop by remember { mutableStateOf(false) } var pendingScrollToTop by remember { mutableStateOf(false) }
var sawRefreshForPendingScroll by remember { mutableStateOf(false) } var sawRefreshForPendingScroll by remember { mutableStateOf(false) }
var staleEntries by remember { mutableStateOf(emptyList<IndexEntry>()) }
val imeBottomPadding = with(LocalDensity.current) { val imeBottomPadding = with(LocalDensity.current) {
WindowInsets.ime.getBottom(this).toDp() WindowInsets.ime.getBottom(this).toDp()
} }
@@ -74,21 +72,8 @@ internal fun SearchResultsPane(
restoredScrollPosition = true restoredScrollPosition = true
pendingScrollToTop = query.isNotBlank() pendingScrollToTop = query.isNotBlank()
sawRefreshForPendingScroll = false sawRefreshForPendingScroll = false
if (query.isBlank()) {
staleEntries = emptyList()
} }
} }
}
val loaded = results.itemSnapshotList.items.filterNotNull()
SideEffect {
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) { LaunchedEffect(results.loadState.refresh, pendingScrollToTop) {
if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) { if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) {
@@ -140,7 +125,6 @@ internal fun SearchResultsPane(
listState = listState, listState = listState,
showDictionaryName = showDictionaryName, showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(bottom = imeBottomPadding), contentPadding = PaddingValues(bottom = imeBottomPadding),
staleEntries = shownStale,
) )
} }
} }
@@ -1,6 +1,5 @@
package com.example.research.ui.main.components package com.example.research.ui.main.components
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.* import androidx.compose.foundation.lazy.*
@@ -30,11 +29,8 @@ fun SearchResultsList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(), listState: LazyListState = rememberLazyListState(),
showDictionaryName: Boolean = true, showDictionaryName: Boolean = true,
contentPadding: PaddingValues = PaddingValues(), contentPadding: PaddingValues = PaddingValues()
staleEntries: List<IndexEntry> = emptyList(),
) { ) {
val showStale = staleEntries.isNotEmpty() && results.itemCount == 0
val rowCount = if (showStale) staleEntries.size else results.itemCount
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.testTag("search_results") .testTag("search_results")
@@ -44,25 +40,11 @@ fun SearchResultsList(
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items( items(
count = rowCount, count = results.itemCount,
key = { index -> key = results.itemKey { "${it.dictionaryPath}:${it.offset.value}:${it.word}" },
val entry = if (showStale) staleEntries.getOrNull(index) contentType = results.itemContentType { "search_result" }
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" }
) { index -> ) { index ->
val entry = if (showStale) staleEntries.getOrNull(index) results[index]?.let { entry ->
else if (index in 0 until results.itemCount) results.peek(index) else null
if (entry != null) {
Box(
modifier = Modifier.animateItem(
fadeInSpec = if (index < 8) tween(150) else null,
placementSpec = null,
fadeOutSpec = null
)
) {
SearchResultItem( SearchResultItem(
entry = entry, entry = entry,
resultIndex = index, resultIndex = index,
@@ -71,7 +53,6 @@ fun SearchResultsList(
) )
} }
} }
}
when (results.loadState.append) { when (results.loadState.append) {
is LoadState.Error -> { is LoadState.Error -> {
@@ -1,13 +1,6 @@
package com.example.research.ui.navigation package com.example.research.ui.navigation
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
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.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -79,12 +72,6 @@ fun AppNavigation(
.windowSizeClass .windowSizeClass
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND) .isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
LaunchedEffect(isWideScreen) {
if (isWideScreen) {
screenStack.removeAll { it == Screen.Article }
}
}
BackHandler(enabled = screenStack.size > 1 && !(currentScreen == Screen.Settings && isOperationActive)) { BackHandler(enabled = screenStack.size > 1 && !(currentScreen == Screen.Settings && isOperationActive)) {
if (currentScreen == Screen.Article) { if (currentScreen == Screen.Article) {
if (searchViewModel.navigateBackFromArticle()) { if (searchViewModel.navigateBackFromArticle()) {
@@ -105,21 +92,7 @@ fun AppNavigation(
shouldShowSettings -> Screen.Settings shouldShowSettings -> Screen.Settings
else -> currentScreen else -> currentScreen
} }
AnimatedContent( when (effectiveScreen) {
targetState = effectiveScreen,
transitionSpec = {
val forward = initialState.ordinal < targetState.ordinal
(
fadeIn(tween(180)) +
slideInHorizontally(tween(180)) { if (forward) it / 3 else -it / 3 }
).togetherWith(
fadeOut(tween(180)) +
slideOutHorizontally(tween(180)) { if (forward) -it / 3 else it / 3 }
)
},
label = "screenTransition",
) { screen ->
when (screen) {
Screen.Home -> { Screen.Home -> {
MainRoute( MainRoute(
searchViewModel = searchViewModel, searchViewModel = searchViewModel,
@@ -170,5 +143,4 @@ fun AppNavigation(
) )
} }
} }
}
} }
@@ -3,11 +3,6 @@ package com.example.research.ui.settings
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -271,11 +266,7 @@ private fun CollapsibleSettingsSection(
testTag = testTag testTag = testTag
) )
AnimatedVisibility( if (isExpanded) {
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -7,7 +7,6 @@ import androidx.lifecycle.viewModelScope
import com.example.research.DictionaryStatus import com.example.research.DictionaryStatus
import com.example.research.R import com.example.research.R
import com.example.research.common.progress.DictionaryProgressStateHolder import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.common.util.DateUtils
import com.example.research.core.domain.model.AppTheme import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
@@ -17,7 +16,6 @@ import com.example.research.core.domain.usecase.DictionarySourceValidator
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
import com.example.research.core.util.OperationResult import com.example.research.core.util.OperationResult
import com.example.research.data.local.preferences.PreferencesManager import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.local.preferences.SectionExpansionStates
import com.example.research.data.repository.LocalDictionaryRepository import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.model.DownloadState import com.example.research.feature.download.model.DownloadState
@@ -63,7 +61,6 @@ class SettingsViewModel(
private val dictionaryImportManager: DictionaryImportManager, private val dictionaryImportManager: DictionaryImportManager,
progressStateHolder: DictionaryProgressStateHolder, progressStateHolder: DictionaryProgressStateHolder,
appVersion: String, appVersion: String,
initialSectionExpansion: SectionExpansionStates,
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase( private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
@@ -71,14 +68,7 @@ class SettingsViewModel(
validator = DictionarySourceValidator(), validator = DictionarySourceValidator(),
) )
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(SettingsUiState(appVersion = appVersion))
SettingsUiState(
appVersion = appVersion,
isThemeExpanded = initialSectionExpansion.isThemeExpanded,
isLanguageExpanded = initialSectionExpansion.isLanguageExpanded,
isDictionariesExpanded = initialSectionExpansion.isDictionariesExpanded,
)
)
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
val progressSnapshot = progressStateHolder.progressSnapshot val progressSnapshot = progressStateHolder.progressSnapshot
@@ -101,26 +91,9 @@ class SettingsViewModel(
init { init {
setupStateObservation() setupStateObservation()
observeDictionariesForSeedFlag() observeDictionariesForSeedFlag()
persistDictionaryStatus()
initialize() initialize()
} }
private fun persistDictionaryStatus() {
viewModelScope.launch {
dictionaryStatus.collect { status ->
val persistedName = when (status) {
DictionaryStatus.UpToDate -> "UpToDate"
DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
DictionaryStatus.Empty -> "Empty"
else -> null
}
if (persistedName != null) {
preferencesManager.saveDictionaryStatus("$persistedName|${DateUtils.getCurrentDateString()}")
}
}
}
}
private fun observeDictionariesForSeedFlag() { private fun observeDictionariesForSeedFlag() {
viewModelScope.launch { viewModelScope.launch {
localDictionaryRepository.dictionaries.collect { dictionaries -> localDictionaryRepository.dictionaries.collect { dictionaries ->
@@ -179,9 +152,7 @@ class SettingsViewModel(
if (wasIndexing && !isIndexing) { if (wasIndexing && !isIndexing) {
if (isDownloadInProgress) { if (isDownloadInProgress) {
isDownloadInProgress = false isDownloadInProgress = false
dictionaryStatus.value = withContext(Dispatchers.IO) { dictionaryStatus.value = DictionaryStatus.UpToDate
evaluateDictionaryStatus()
}
} }
} }
wasIndexing = isIndexing wasIndexing = isIndexing
@@ -580,23 +551,11 @@ class SettingsViewModel(
} else if (isDownloadInProgress) { } else if (isDownloadInProgress) {
dictionaryStatus.value = DictionaryStatus.UpToDate dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false isDownloadInProgress = false
} else {
val savedStatus = preferencesManager.dictionaryStatus()
val savedStatusName = savedStatus?.substringBefore('|')
val savedStatusDate = savedStatus?.substringAfter('|', "")
val restoredStatus = when (savedStatusName) {
"UpToDate" -> DictionaryStatus.UpToDate
"NeedsUpdate" -> DictionaryStatus.NeedsUpdate
else -> null
}
if (restoredStatus != null && savedStatusDate == DateUtils.getCurrentDateString()) {
dictionaryStatus.value = restoredStatus
} else { } else {
dictionaryStatus.value = DictionaryStatus.Checking dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() } val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status dictionaryStatus.value = status
} }
}
if (result.data > 0) { if (result.data > 0) {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
@@ -1,14 +1,6 @@
package com.example.research.ui.settings.components 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -23,10 +15,8 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -120,11 +110,7 @@ fun DictionaryManagement(
testTag = "dictionary_header" testTag = "dictionary_header"
) )
AnimatedVisibility( if (isExpanded) {
visible = isExpanded,
enter = expandVertically(animationSpec = tween(250)) + fadeIn(tween(250)),
exit = shrinkVertically(animationSpec = tween(200)) + fadeOut(tween(200)),
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -274,27 +260,12 @@ private fun DictionaryListSection(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDeleteBlocked: () -> Boolean = { false } isDeleteBlocked: () -> Boolean = { false }
) { ) {
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
LaunchedEffect(dictionaries) {
val visiblePaths = dictionaries.mapTo(mutableSetOf(), Dictionary::path)
appearedPaths = appearedPaths intersect visiblePaths
}
Column( Column(
modifier = modifier, modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
dictionaries.forEach { dictionary -> dictionaries.forEach { dictionary ->
key(dictionary.path) { key(dictionary.path) {
val hasAppeared = dictionary.path in appearedPaths
val rowAlpha by animateFloatAsState(
targetValue = if (hasAppeared) 1f else 0f,
animationSpec = tween(150),
label = "dictionaryRowAppear",
)
LaunchedEffect(dictionary.path) {
appearedPaths = appearedPaths + dictionary.path
}
Box(modifier = Modifier.alpha(rowAlpha)) {
DictionaryListItem( DictionaryListItem(
dictionary = dictionary, dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) }, onToggle = { onToggleDictionary(dictionary.path) },
@@ -304,5 +275,4 @@ private fun DictionaryListSection(
} }
} }
} }
}
} }
@@ -9,6 +9,4 @@ object Spacing {
val iconStart = 32.dp val iconStart = 32.dp
val searchTextStart = 68.dp val searchTextStart = 68.dp
val listItemVertical = 8.dp val listItemVertical = 8.dp
val contentStart = 32.dp
} }
+2 -2
View File
@@ -13,6 +13,7 @@
<string name="language_russian">Русский</string> <string name="language_russian">Русский</string>
<string name="language_system">Системный</string> <string name="language_system">Системный</string>
<string name="clear_search">Очистить поиск</string> <string name="clear_search">Очистить поиск</string>
<string name="article_no_selected">Статья не выбрана</string>
<string name="article_return_to_search">Вернуться к поиску</string> <string name="article_return_to_search">Вернуться к поиску</string>
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string> <string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
<string name="error_loading_article">Ошибка загрузки: %1$s</string> <string name="error_loading_article">Ошибка загрузки: %1$s</string>
@@ -47,7 +48,6 @@
<string name="notification_download_error_title">Ошибка загрузки</string> <string name="notification_download_error_title">Ошибка загрузки</string>
<string name="notification_download_error_text">Не удалось скачать словари</string> <string name="notification_download_error_text">Не удалось скачать словари</string>
<string name="notification_indexing_title">Индексация словарей</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_open_source_file">Не удалось открыть исходный файл</string>
<string name="archive_failed_create_output_file">Не удалось создать выходной файл</string> <string name="archive_failed_create_output_file">Не удалось создать выходной файл</string>
<string name="archive_failed_delete_file">Не удалось удалить файл: %1$s</string> <string name="archive_failed_delete_file">Не удалось удалить файл: %1$s</string>
@@ -77,7 +77,7 @@
<string name="dictionary_source_duplicate_prefix">Этот словарь уже существует (одинаковый префикс)</string> <string name="dictionary_source_duplicate_prefix">Этот словарь уже существует (одинаковый префикс)</string>
<string name="dictionary_source_url_empty">URL не может быть пустым</string> <string name="dictionary_source_url_empty">URL не может быть пустым</string>
<string name="dictionary_source_invalid_protocol">Должен начинаться с http:// или https://</string> <string name="dictionary_source_invalid_protocol">Должен начинаться с http:// или https://</string>
<string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dsl.dz</string> <string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dz</string>
<string name="dictionary_source_too_long">URL слишком длинный (максимум 2048 символов)</string> <string name="dictionary_source_too_long">URL слишком длинный (максимум 2048 символов)</string>
<string name="dictionary_source_invalid_url">Неверный формат URL</string> <string name="dictionary_source_invalid_url">Неверный формат URL</string>
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string> <string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
+2 -2
View File
@@ -13,6 +13,7 @@
<string name="language_russian">Russian</string> <string name="language_russian">Russian</string>
<string name="language_system">System</string> <string name="language_system">System</string>
<string name="clear_search">Clear search</string> <string name="clear_search">Clear search</string>
<string name="article_no_selected">No article selected</string>
<string name="article_return_to_search">Return to search</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_checking_dictionaries">Failed to check dictionary status</string>
<string name="error_loading_article">Loading error: %1$s</string> <string name="error_loading_article">Loading error: %1$s</string>
@@ -47,7 +48,6 @@
<string name="notification_download_error_title">Download error</string> <string name="notification_download_error_title">Download error</string>
<string name="notification_download_error_text">Failed to download dictionaries</string> <string name="notification_download_error_text">Failed to download dictionaries</string>
<string name="notification_indexing_title">Indexing 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_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_create_output_file">Failed to create output file</string>
<string name="archive_failed_delete_file">Failed to delete file: %1$s</string> <string name="archive_failed_delete_file">Failed to delete file: %1$s</string>
@@ -75,7 +75,7 @@
<string name="dictionary_source_duplicate_prefix">This dictionary already exists</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_url_empty">URL cannot be empty</string>
<string name="dictionary_source_invalid_protocol">Must start with http:// or https://</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 .dsl.dz</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_too_long">URL is too long (max 2048 characters)</string>
<string name="dictionary_source_invalid_url">Invalid URL format</string> <string name="dictionary_source_invalid_url">Invalid URL format</string>
<string name="dictionary_source_suspicious_characters">URL contains invalid characters</string> <string name="dictionary_source_suspicious_characters">URL contains invalid characters</string>
@@ -1,9 +0,0 @@
# Strip android.util.Log warnings/errors from release builds.
# Debug builds keep full logging for diagnostics.
-assumenosideeffects class android.util.Log {
public static int w(java.lang.String, java.lang.String);
public static int w(java.lang.String, java.lang.String, java.lang.Throwable);
public static int w(java.lang.String, java.lang.Throwable);
public static int e(java.lang.String, java.lang.String);
public static int e(java.lang.String, java.lang.String, java.lang.Throwable);
}
@@ -1,16 +0,0 @@
- The app starts faster
- 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
- Improved app stability
- Faster dictionary indexing
- Updated build tooling and documentation
- Long link-heavy articles no longer freeze while scrolling
- Large search result lists render faster
- Dark article colors are readable in the dark theme
@@ -1,16 +0,0 @@
- Приложение запускается быстрее
- Плавные анимации переходов между экранами
- Статьи корректно отображают переносы строк, отступы и цвета из DSL-разметки
- Табуляции и лишние пробелы в статьях больше не ломают вёрстку
- Заголовок статьи можно скопировать, тап по статье сбрасывает выделение
- Адреса источников принимают формат host:port
- Загрузка словарей автоматически повторяется при переключении сети
- Уведомления о прогрессе загрузки стали надёжнее
- Результаты поиска очищаются мгновенно при стирании запроса
- Остаточные файлы после отменённых загрузок удаляются автоматически
- Повышена стабильность приложения
- Ускорена индексация словарей
- Обновлены тулинг сборки и документация
- Длинные статьи со ссылками больше не подвисают при прокрутке
- Большие списки результатов поиска отображаются быстрее
- Тёмные цвета статей читаемы в тёмной теме
+4 -4
View File
@@ -1,12 +1,12 @@
[versions] [versions]
aboutlibraries = "15.2.0" aboutlibraries = "15.2.0"
activity_compose = "1.13.0" activity_compose = "1.13.0"
agp = "9.4.1" agp = "9.3.1"
compose_bom = "2026.09.00" compose_bom = "2026.08.00"
core = "1.19.1" core = "1.19.0"
datastore_preferences = "1.2.1" datastore_preferences = "1.2.1"
documentfile = "1.1.0" documentfile = "1.1.0"
kotlin = "2.4.20" kotlin = "2.4.10"
lifecycle_runtime_ktx = "2.11.0" lifecycle_runtime_ktx = "2.11.0"
okhttp = "5.5.0" okhttp = "5.5.0"
paging = "3.5.1" paging = "3.5.1"