Compare commits
46
Commits
cf64986371
..
1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7c080939c | ||
|
|
2c8668dab3 | ||
|
|
e4e50db08e | ||
|
|
98ec38f272 | ||
|
|
6040ca8db5 | ||
|
|
71fea4472b | ||
|
|
b5fc7e7246 | ||
|
|
447d086cdf | ||
|
|
ac9a22dbe1 | ||
|
|
cf29f19511 | ||
|
|
ed1e1db7f2 | ||
|
|
a5b54def51 | ||
|
|
ee19f84f91 | ||
|
|
4e42a269a7 | ||
|
|
4caf3a534d | ||
|
|
c0c9211b82 | ||
|
|
f5880d72ad | ||
|
|
95f4317eaf | ||
|
|
df96deb1fc | ||
|
|
451267688a | ||
|
|
1d84eaf9c0 | ||
|
|
59115ff9a3 | ||
|
|
eb918ca09f | ||
|
|
638e886498 | ||
|
|
c2b2bf1c8a | ||
|
|
30aa86f39f | ||
|
|
8a506763a5 | ||
|
|
4ce39d0e4f | ||
|
|
058330dd34 | ||
|
|
f39aa2a341 | ||
|
|
f51684367a | ||
|
|
bddcb99177 | ||
|
|
2dadfa11e9 | ||
|
|
2394c7cf5e | ||
|
|
830f99578a | ||
|
|
214b9f91e4 | ||
|
|
edf6a6074e | ||
|
|
8319973161 | ||
|
|
1631e5384c | ||
|
|
1d4990e5b7 | ||
|
|
8c6e43f3db | ||
|
|
2f22144d19 | ||
|
|
9807a0cf27 | ||
|
|
de27eecd9e | ||
|
|
c4b3a57ccb | ||
|
|
d96fad9cab |
No files matched your search
@@ -0,0 +1,21 @@
|
|||||||
|
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.
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#screenshots">Screenshots</a> •
|
<a href="#screenshots">Screenshots</a> •
|
||||||
<a href="#features">Features</a> •
|
<a href="#features">Features</a> •
|
||||||
<a href="#architecture">Architecture</a> •
|
|
||||||
<a href="#dictionary-pipeline">Pipeline</a> •
|
<a href="#dictionary-pipeline">Pipeline</a> •
|
||||||
<a href="#build">Build</a>
|
<a href="#build">Build</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -23,31 +22,17 @@ 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.
|
- Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases.
|
||||||
- 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.
|
- Background processing with progress shown in Settings and notifications; downloads are cancelled with a button on the progress notification.
|
||||||
- Dictionary management with enable, disable, update, and delete actions.
|
- Dictionary management with enable, disable, and update actions; deleting a dictionary is done with a swipe.
|
||||||
- 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.
|
||||||
@@ -94,16 +79,16 @@ The main search screen remains usable as long as at least one indexed dictionary
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- Kotlin 2.4.10
|
- Kotlin 2.4.20
|
||||||
- Jetpack Compose (BOM 2026.08.00)
|
- Jetpack Compose (BOM 2026.09.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.4.0
|
- OkHttp 5.5.0
|
||||||
- kotlinx.serialization 1.11.0
|
- kotlinx.serialization 1.11.0
|
||||||
- AboutLibraries 15.0.4 metadata generation
|
- AboutLibraries 15.2.0 metadata generation
|
||||||
- Android Gradle Plugin 9.3.1
|
- Android Gradle Plugin 9.4.1
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -111,8 +96,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`. English and Russian resources are packaged together.
|
The release APK targets `arm64-v8a`.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
[MIT](LICENSE)
|
||||||
@@ -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 = 8
|
versionCode = 9
|
||||||
versionName = "1.5.0"
|
versionName = "1.6.0"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import com.example.research.common.ui.AppLocaleProvider
|
|||||||
import com.example.research.common.ui.BackgroundTextMeasurementProvider
|
import com.example.research.common.ui.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
|
||||||
@@ -51,6 +52,8 @@ 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 {
|
||||||
@@ -69,6 +72,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
app.dictionaryImportManager,
|
app.dictionaryImportManager,
|
||||||
app.dictionaryProgressStateHolder,
|
app.dictionaryProgressStateHolder,
|
||||||
versionName,
|
versionName,
|
||||||
|
initialSectionExpansion ?: SectionExpansionStates(
|
||||||
|
isThemeExpanded = false,
|
||||||
|
isLanguageExpanded = false,
|
||||||
|
isDictionariesExpanded = true,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +92,7 @@ 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 {
|
||||||
@@ -177,6 +186,21 @@ 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_indexing_title,
|
titleRes = R.string.notification_preparing_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, 100)
|
val pct = (p * 100f).toInt().coerceIn(0, 99)
|
||||||
|
|
||||||
return Snapshot(
|
return Snapshot(
|
||||||
progress = p,
|
progress = p,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class DictionaryProgressPresenter(
|
|||||||
|
|
||||||
fun beginFlow() {
|
fun beginFlow() {
|
||||||
terminalActive = false
|
terminalActive = false
|
||||||
|
notifications.cancelTerminalNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun showImportSuccess() {
|
override fun showImportSuccess() {
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ interface NotificationPort {
|
|||||||
fun showDownloadSuccess()
|
fun showDownloadSuccess()
|
||||||
fun showError()
|
fun showError()
|
||||||
fun cancelProgressNotification()
|
fun cancelProgressNotification()
|
||||||
|
fun cancelTerminalNotification()
|
||||||
fun hasActiveProgressNotification(): Boolean
|
fun hasActiveProgressNotification(): Boolean
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ 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
|
||||||
@@ -91,7 +92,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
|
|||||||
.setContentIntent(mainActivityPendingIntent)
|
.setContentIntent(mainActivityPendingIntent)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build()
|
.build()
|
||||||
safeNotify(notification)
|
safeNotifyTerminal(notification)
|
||||||
}
|
}
|
||||||
fun showErrorNotification() {
|
fun showErrorNotification() {
|
||||||
if (!canShowNotification()) return
|
if (!canShowNotification()) return
|
||||||
@@ -103,13 +104,17 @@ class NotificationHelper(private val context: Context) : NotificationPort {
|
|||||||
.setContentIntent(mainActivityPendingIntent)
|
.setContentIntent(mainActivityPendingIntent)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build()
|
.build()
|
||||||
safeNotify(notification)
|
safeNotifyTerminal(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(
|
||||||
@@ -161,6 +166,14 @@ 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
|
||||||
@@ -171,7 +184,7 @@ class NotificationHelper(private val context: Context) : NotificationPort {
|
|||||||
.setContentIntent(mainActivityPendingIntent)
|
.setContentIntent(mainActivityPendingIntent)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build()
|
.build()
|
||||||
safeNotify(notification)
|
safeNotifyTerminal(notification)
|
||||||
}
|
}
|
||||||
fun createForegroundNotification(): Notification {
|
fun createForegroundNotification(): Notification {
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
|||||||
@@ -102,12 +102,15 @@ 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)
|
||||||
!trimmed.endsWith(".gz") && !trimmed.endsWith(".dsl") && !trimmed.endsWith(".dz") ->
|
!lowerTemplate.endsWith(".gz") &&
|
||||||
|
!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)
|
||||||
@@ -132,8 +135,15 @@ data class DictionarySource(
|
|||||||
val parts = afterProtocol.split("/")
|
val parts = afterProtocol.split("/")
|
||||||
if (parts.size < 2) return false
|
if (parts.size < 2) return false
|
||||||
|
|
||||||
val domain = parts[0]
|
val hostWithPort = parts[0]
|
||||||
if (domain.isEmpty()) return false
|
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
|
||||||
if (domain != "localhost" && !domain.contains(".")) return false
|
if (domain != "localhost" && !domain.contains(".")) return false
|
||||||
|
|
||||||
val filename = parts.last()
|
val filename = parts.last()
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ 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")
|
||||||
@@ -26,6 +33,7 @@ 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 }
|
||||||
@@ -79,6 +87,24 @@ 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,14 +180,19 @@ internal fun parseDslArticleContent(rawContent: String): String {
|
|||||||
|
|
||||||
while (i < len) {
|
while (i < len) {
|
||||||
val lineStart = i
|
val lineStart = i
|
||||||
while (i < len && rawContent[i] != '\n') i++
|
while (i < len && rawContent[i] != '\n' && rawContent[i] != '\r') i++
|
||||||
val lineEnd = i
|
val lineEnd = i
|
||||||
if (i < len && rawContent[i] == '\n') i++
|
if (i < len && rawContent[i] == '\r') {
|
||||||
|
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.trimEnd()
|
val trimmed = line.trim()
|
||||||
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(dict.name, 1.0f)
|
progressTracker?.updateFileProgress(File(dict.path).name, 1.0f)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutableIndexingProgress.emit(
|
mutableIndexingProgress.emit(
|
||||||
|
|||||||
+20
-4
@@ -64,7 +64,14 @@ class DictionaryChecker(
|
|||||||
val previousDate = DateUtils.getPreviousDateString()
|
val previousDate = DateUtils.getPreviousDateString()
|
||||||
val dictFiles = getDictFiles(folderUri, files)
|
val dictFiles = getDictFiles(folderUri, files)
|
||||||
|
|
||||||
val allHaveToday = enabledSources.all { source ->
|
val datedSources = enabledSources.filter { source ->
|
||||||
|
DictionarySource.hasDatePlaceholder(source.urlTemplate)
|
||||||
|
}
|
||||||
|
if (datedSources.isEmpty()) {
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
|
||||||
|
val allHaveToday = datedSources.all { source ->
|
||||||
dictFiles.hasFileForSource(source, currentDate)
|
dictFiles.hasFileForSource(source, currentDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,19 +79,28 @@ class DictionaryChecker(
|
|||||||
return@withContext true
|
return@withContext true
|
||||||
}
|
}
|
||||||
|
|
||||||
val allHaveYesterday = enabledSources.all { source ->
|
val allHaveYesterday = datedSources.all { source ->
|
||||||
dictFiles.hasFileForSource(source, previousDate)
|
dictFiles.hasFileForSource(source, previousDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allHaveYesterday) {
|
if (allHaveYesterday) {
|
||||||
val serverHasToday = enabledSources.any { source ->
|
val serverHasToday = datedSources.any { source ->
|
||||||
isSourceAvailableOnServer(source, currentDate)
|
isSourceAvailableOnServer(source, currentDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
return@withContext !serverHasToday
|
return@withContext !serverHasToday
|
||||||
}
|
}
|
||||||
|
|
||||||
false
|
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: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error checking currency: ${e.message}", e)
|
Log.e(TAG, "Error checking currency: ${e.message}", e)
|
||||||
false
|
false
|
||||||
|
|||||||
+7
-1
@@ -88,6 +88,12 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -125,7 +131,7 @@ class DictionaryForegroundService : Service() {
|
|||||||
startForegroundService()
|
startForegroundService()
|
||||||
}
|
}
|
||||||
ACTION_FINISH -> {
|
ACTION_FINISH -> {
|
||||||
stopForegroundService(removeNotification = false)
|
stopForegroundService(removeNotification = true)
|
||||||
}
|
}
|
||||||
ACTION_STOP -> {
|
ACTION_STOP -> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ 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()
|
||||||
@@ -136,6 +137,9 @@ 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,18 +1,25 @@
|
|||||||
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.AlertDialog
|
import androidx.compose.material3.AlertDialogDefaults
|
||||||
|
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
|
||||||
@@ -24,13 +31,17 @@ 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
|
||||||
@@ -44,6 +55,7 @@ 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)
|
||||||
@@ -66,6 +78,15 @@ 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 = {
|
||||||
@@ -93,24 +114,20 @@ fun AboutScreen(
|
|||||||
.semantics { testTagsAsResourceId = true }
|
.semantics { testTagsAsResourceId = true }
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
) {
|
) {
|
||||||
item(key = "about_header") {
|
item(key = "about_header", contentType = "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 = {
|
onToggle = onToggleLibrary,
|
||||||
expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
library.uniqueId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLicenseClick = { dialogLicense = it },
|
onLicenseClick = { dialogLicense = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -171,27 +188,33 @@ 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: () -> Unit,
|
onToggle: (String) -> 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)
|
||||||
|
|
||||||
Surface(
|
val background by animateColorAsState(
|
||||||
modifier = Modifier
|
targetValue = if (expanded) {
|
||||||
.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
|
||||||
},
|
},
|
||||||
onClick = onToggle
|
label = "aboutLibraryRowBackground",
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("about_library_row")
|
||||||
|
.semantics { testTagsAsResourceId = true },
|
||||||
|
color = background,
|
||||||
|
onClick = { onToggle(library.uniqueId) }
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -291,32 +314,79 @@ 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,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
var open by remember { mutableStateOf(false) }
|
||||||
onDismissRequest = onDismiss,
|
var wasOpen by remember { mutableStateOf(false) }
|
||||||
title = { Text(license.name) },
|
LaunchedEffect(Unit) { open = true }
|
||||||
text = {
|
val scale by animateFloatAsState(
|
||||||
Text(
|
targetValue = if (open) 1f else 0.92f,
|
||||||
text = license.content ?: stringResource(R.string.license_text_unavailable),
|
animationSpec = tween(180),
|
||||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
label = "licenseDialogScale",
|
||||||
)
|
)
|
||||||
},
|
val alpha by animateFloatAsState(
|
||||||
confirmButton = {
|
targetValue = if (open) 1f else 0f,
|
||||||
TextButton(onClick = onDismiss) {
|
animationSpec = tween(180),
|
||||||
Text(stringResource(R.string.action_close))
|
label = "licenseDialogAlpha",
|
||||||
}
|
)
|
||||||
},
|
LaunchedEffect(open) {
|
||||||
dismissButton = {
|
if (open) {
|
||||||
license.url?.takeIf(String::isNotBlank)?.let { url ->
|
wasOpen = true
|
||||||
TextButton(onClick = { onOpenWebsite(url) }) {
|
} else if (wasOpen) {
|
||||||
Text(stringResource(R.string.website))
|
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 = license.content ?: stringResource(R.string.license_text_unavailable),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f, fill = false)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||||
|
) {
|
||||||
|
license.url?.takeIf(String::isNotBlank)?.let { url ->
|
||||||
|
TextButton(onClick = { onOpenWebsite(url) }) {
|
||||||
|
Text(stringResource(R.string.website))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(onClick = { open = false }) {
|
||||||
|
Text(stringResource(R.string.action_close))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -11,6 +12,7 @@ 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
|
||||||
@@ -18,6 +20,9 @@ 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
|
||||||
@@ -48,6 +53,54 @@ 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?,
|
||||||
@@ -63,7 +116,11 @@ 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
|
||||||
|
|
||||||
@@ -123,35 +180,42 @@ fun ArticleContent(
|
|||||||
val isHeavyArticle =
|
val isHeavyArticle =
|
||||||
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
|
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
|
||||||
|
|
||||||
LazyColumn(
|
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
|
||||||
state = listState,
|
val displayBlocks = remember(
|
||||||
modifier = modifier
|
articleState.dslContent,
|
||||||
.fillMaxSize()
|
baseTextStyle,
|
||||||
.onSizeChanged { viewportSize = it }
|
darkTheme
|
||||||
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
|
|
||||||
.padding(end = 16.dp)
|
|
||||||
.testTag("article_content")
|
|
||||||
.semantics {
|
|
||||||
testTagsAsResourceId = true
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
val merged =
|
||||||
items = articleState.dslContent,
|
mergeDslBlocks(articleState.dslContent)
|
||||||
key = { index, _ ->
|
if (darkTheme) {
|
||||||
// Use stable key combining article save key and index
|
merged.map {
|
||||||
"$articleSaveKey:$index"
|
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
|
||||||
},
|
}
|
||||||
contentType = { _, _ -> "article_block" }
|
} else {
|
||||||
) { index, block ->
|
merged
|
||||||
val followedByContinuation =
|
}
|
||||||
articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true
|
}
|
||||||
val textContent = @Composable {
|
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 ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(
|
.padding(start = Spacing.contentStart + indentStep * block.indent)
|
||||||
start = Spacing.iconStart + indentStep * block.indent
|
|
||||||
)
|
|
||||||
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
|
|
||||||
) {
|
) {
|
||||||
ArticleLinkText(
|
ArticleLinkText(
|
||||||
block = block,
|
block = block,
|
||||||
@@ -168,13 +232,18 @@ 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 -> {
|
is ArticleState.Error -> {
|
||||||
Column(
|
Column(
|
||||||
@@ -196,16 +265,7 @@ fun ArticleContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
null -> {
|
null -> {
|
||||||
Column(
|
Box(modifier = modifier.fillMaxSize())
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.article_no_selected),
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,13 +275,15 @@ 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 -> stringResource(R.string.article_no_selected)
|
null -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
val backIcon = painterResource(R.drawable.ic_arrow_back)
|
val backIcon = painterResource(R.drawable.ic_arrow_back)
|
||||||
@@ -230,20 +292,24 @@ fun ArticleTitleBar(
|
|||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
modifier = modifier.fillMaxWidth()
|
modifier = modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Box(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
|
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
|
||||||
.padding(
|
.then(
|
||||||
top = Spacing.topBarPadding,
|
if (compact) {
|
||||||
bottom = Spacing.topBarPadding
|
Modifier.padding(vertical = Spacing.listItemVertical)
|
||||||
)
|
} else {
|
||||||
.height(Spacing.topBarHeight)
|
Modifier
|
||||||
|
.padding(top = Spacing.topBarPadding, bottom = Spacing.topBarPadding)
|
||||||
|
.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),
|
||||||
@@ -257,18 +323,21 @@ fun ArticleTitleBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
key(selectionReset) {
|
||||||
text = title,
|
SelectionContainer {
|
||||||
style = MaterialTheme.typography.dictionaryTitleLarge,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
text = title,
|
||||||
maxLines = 1,
|
style = MaterialTheme.typography.dictionaryTitleLarge,
|
||||||
overflow = TextOverflow.Ellipsis,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier
|
maxLines = 1,
|
||||||
.align(Alignment.CenterStart)
|
overflow = TextOverflow.Ellipsis,
|
||||||
.padding(start = if (showBackButton) Spacing.searchTextStart else 16.dp)
|
modifier = Modifier
|
||||||
.testTag("article_title")
|
.padding(start = if (showBackButton) Spacing.searchTextStart - Spacing.iconStart - 24.dp else 16.dp)
|
||||||
.semantics { testTagsAsResourceId = true }
|
.testTag("article_title")
|
||||||
)
|
.semantics { testTagsAsResourceId = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,22 +350,31 @@ internal fun ArticleScreen(
|
|||||||
onRefClick: ((String) -> Unit)? = null,
|
onRefClick: ((String) -> Unit)? = null,
|
||||||
onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
|
onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
val titleSelectionReset = remember { mutableIntStateOf(0) }
|
||||||
topBar = {
|
Box(
|
||||||
ArticleTitleBar(
|
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(
|
||||||
articleState = articleState,
|
articleState = articleState,
|
||||||
showBackButton = true,
|
onNavigateBack = onNavigateBack,
|
||||||
onNavigateBack = onNavigateBack
|
onRefClick = onRefClick,
|
||||||
|
onScrollPositionChanged = onScrollPositionChanged,
|
||||||
|
modifier = Modifier.padding(padding)
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
contentWindowInsets = AppWindowInsets.contentWithNavigation
|
|
||||||
) { padding ->
|
|
||||||
ArticleContent(
|
|
||||||
articleState = articleState,
|
|
||||||
onNavigateBack = onNavigateBack,
|
|
||||||
onRefClick = onRefClick,
|
|
||||||
onScrollPositionChanged = onScrollPositionChanged,
|
|
||||||
modifier = Modifier.padding(padding)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,40 @@ 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,
|
||||||
@@ -36,11 +70,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -54,9 +83,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)
|
||||||
@@ -74,16 +103,38 @@ object DslAnnotatedParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun flushBlock() {
|
fun flushBlock() {
|
||||||
if (builder.length > 0) {
|
val text = builder.toAnnotatedString()
|
||||||
|
if (text.any { !it.isWhitespace() }) {
|
||||||
result.add(DslBlock(
|
result.add(DslBlock(
|
||||||
text = builder.toAnnotatedString(),
|
text = text,
|
||||||
indent = currentIndent,
|
indent = currentIndent,
|
||||||
lazyRefs = lazyRefs.toList()
|
lazyRefs = lazyRefs.toList()
|
||||||
))
|
))
|
||||||
builder = AnnotatedString.Builder()
|
|
||||||
stack.forEach { builder.pushStyle(it) }
|
|
||||||
lazyRefs.clear()
|
|
||||||
}
|
}
|
||||||
|
builder = AnnotatedString.Builder()
|
||||||
|
stack.forEach { builder.pushStyle(it) }
|
||||||
|
lazyRefs.clear()
|
||||||
|
lastAppended = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendChar(char: Char, hard: Boolean = false) {
|
||||||
|
if (char == ' ' && !hard) {
|
||||||
|
if (lastAppended == null || lastAppended == ' ') return
|
||||||
|
}
|
||||||
|
lastAppended = char
|
||||||
|
builder.append(char)
|
||||||
|
if (refStack.isNotEmpty()) {
|
||||||
|
refStack.last().second.append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendText(value: String) {
|
||||||
|
if (value.isEmpty()) return
|
||||||
|
builder.append(value)
|
||||||
|
if (refStack.isNotEmpty()) {
|
||||||
|
refStack.last().second.append(value)
|
||||||
|
}
|
||||||
|
lastAppended = value.last()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun marginIndent(tagName: String): Int {
|
fun marginIndent(tagName: String): Int {
|
||||||
@@ -99,20 +150,11 @@ object DslAnnotatedParser {
|
|||||||
when (val char = dsl[i]) {
|
when (val char = dsl[i]) {
|
||||||
'\\' -> {
|
'\\' -> {
|
||||||
if (i + 1 < length) {
|
if (i + 1 < length) {
|
||||||
val next = dsl[i + 1]
|
appendChar(dsl[i + 1], hard = true)
|
||||||
builder.append(next)
|
|
||||||
if (refStack.isNotEmpty()) {
|
|
||||||
refStack.last().second.append(next)
|
|
||||||
}
|
|
||||||
lastChar = next
|
|
||||||
i += 2
|
i += 2
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
builder.append(char)
|
appendChar(char)
|
||||||
if (refStack.isNotEmpty()) {
|
|
||||||
refStack.last().second.append(char)
|
|
||||||
}
|
|
||||||
lastChar = char
|
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
'[' -> {
|
'[' -> {
|
||||||
@@ -149,6 +191,10 @@ object DslAnnotatedParser {
|
|||||||
accentNext = false
|
accentNext = false
|
||||||
}
|
}
|
||||||
pop()
|
pop()
|
||||||
|
if (tagName.startsWith("m")) {
|
||||||
|
flushBlock()
|
||||||
|
currentIndent = 0
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
when (tagName) {
|
when (tagName) {
|
||||||
"'" -> {
|
"'" -> {
|
||||||
@@ -161,17 +207,15 @@ object DslAnnotatedParser {
|
|||||||
} else {
|
} else {
|
||||||
builder.append("\n")
|
builder.append("\n")
|
||||||
}
|
}
|
||||||
lastChar = '\n'
|
|
||||||
}
|
}
|
||||||
"p" -> {
|
"p" -> {
|
||||||
push(labelStyle)
|
push(labelStyle.copy(fontStyle = FontStyle.Italic))
|
||||||
}
|
}
|
||||||
"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 (lastChar != '\n' && lastChar != null) {
|
if (lastAppended != null && !lastAppended!!.isWhitespace()) {
|
||||||
if (refStack.isEmpty()) flushBlock() else builder.append("\n")
|
flushBlock()
|
||||||
}
|
}
|
||||||
lastChar = '\n'
|
|
||||||
currentIndent = marginIndent(tagName)
|
currentIndent = marginIndent(tagName)
|
||||||
push(exampleStyle)
|
push(exampleStyle)
|
||||||
}
|
}
|
||||||
@@ -181,7 +225,18 @@ object DslAnnotatedParser {
|
|||||||
refStack.addLast(builder.length to StringBuilder())
|
refStack.addLast(builder.length to StringBuilder())
|
||||||
}
|
}
|
||||||
"ex", "e" -> push(exampleStyle)
|
"ex", "e" -> push(exampleStyle)
|
||||||
"c", "t", "trn", "lang", "sup" -> push(SpanStyle())
|
"c" -> {
|
||||||
|
val colorName = if (spaceIdx >= 0) {
|
||||||
|
tagWithParams.substring(spaceIdx + 1).trim().lowercase()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
push(when {
|
||||||
|
colorName == null -> labelStyle
|
||||||
|
else -> DSL_NAMED_COLORS[colorName]?.let { SpanStyle(color = it) } ?: SpanStyle()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"t", "trn", "lang", "sup" -> push(SpanStyle())
|
||||||
"com" -> push(SpanStyle(fontStyle = FontStyle.Italic, color = colorScheme.labelText))
|
"com" -> push(SpanStyle(fontStyle = FontStyle.Italic, color = colorScheme.labelText))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,18 +244,23 @@ object DslAnnotatedParser {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
builder.append(char)
|
appendChar(char)
|
||||||
if (refStack.isNotEmpty()) {
|
i++
|
||||||
refStack.last().second.append(char)
|
}
|
||||||
|
'\n' -> {
|
||||||
|
if (refStack.isEmpty()) {
|
||||||
|
flushBlock()
|
||||||
|
} else {
|
||||||
|
appendChar(char)
|
||||||
}
|
}
|
||||||
lastChar = char
|
i++
|
||||||
|
}
|
||||||
|
'\t' -> {
|
||||||
|
appendText("\u2002")
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
builder.append(char)
|
appendChar(char)
|
||||||
if (refStack.isNotEmpty()) {
|
|
||||||
refStack.last().second.append(char)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accentNext) {
|
if (accentNext) {
|
||||||
builder.append('\u0301')
|
builder.append('\u0301')
|
||||||
@@ -210,7 +270,6 @@ object DslAnnotatedParser {
|
|||||||
accentNext = false
|
accentNext = false
|
||||||
}
|
}
|
||||||
|
|
||||||
lastChar = char
|
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,6 +411,35 @@ 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,5 +1,7 @@
|
|||||||
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
|
||||||
@@ -13,6 +15,7 @@ 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
|
||||||
@@ -84,20 +87,27 @@ fun AdaptiveMainScreen(
|
|||||||
.windowInsetsPadding(
|
.windowInsetsPadding(
|
||||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
|
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
|
||||||
)
|
)
|
||||||
.padding(start = 32.dp, end = listEndPadding),
|
.padding(start = Spacing.contentStart, 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,
|
showBackButton = isShowingDetail && articleState != null,
|
||||||
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)
|
||||||
)
|
)
|
||||||
@@ -112,8 +122,13 @@ fun AdaptiveMainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(articleState, isQueryEmpty) {
|
LaunchedEffect(articleState, isQueryEmpty, isLandscape) {
|
||||||
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,6 +14,7 @@ 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
|
||||||
@@ -169,7 +170,7 @@ internal fun MainScreen(
|
|||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
.consumeWindowInsets(paddingValues)
|
.consumeWindowInsets(paddingValues)
|
||||||
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
|
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
|
||||||
.padding(start = 32.dp, end = 16.dp),
|
.padding(start = Spacing.contentStart, end = 16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ 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
|
||||||
@@ -50,6 +51,7 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -72,9 +74,22 @@ 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) {
|
||||||
sawRefreshForPendingScroll = true
|
sawRefreshForPendingScroll = true
|
||||||
@@ -125,6 +140,7 @@ internal fun SearchResultsPane(
|
|||||||
listState = listState,
|
listState = listState,
|
||||||
showDictionaryName = showDictionaryName,
|
showDictionaryName = showDictionaryName,
|
||||||
contentPadding = PaddingValues(bottom = imeBottomPadding),
|
contentPadding = PaddingValues(bottom = imeBottomPadding),
|
||||||
|
staleEntries = shownStale,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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.*
|
||||||
@@ -29,8 +30,11 @@ 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")
|
||||||
@@ -40,17 +44,32 @@ fun SearchResultsList(
|
|||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
count = results.itemCount,
|
count = rowCount,
|
||||||
key = results.itemKey { "${it.dictionaryPath}:${it.offset.value}:${it.word}" },
|
key = { index ->
|
||||||
contentType = results.itemContentType { "search_result" }
|
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" }
|
||||||
) { index ->
|
) { index ->
|
||||||
results[index]?.let { entry ->
|
val entry = if (showStale) staleEntries.getOrNull(index)
|
||||||
SearchResultItem(
|
else if (index in 0 until results.itemCount) results.peek(index) else null
|
||||||
entry = entry,
|
if (entry != null) {
|
||||||
resultIndex = index,
|
Box(
|
||||||
onArticleClick = onArticleClick,
|
modifier = Modifier.animateItem(
|
||||||
showDictionaryName = showDictionaryName
|
fadeInSpec = if (index < 8) tween(150) else null,
|
||||||
)
|
placementSpec = null,
|
||||||
|
fadeOutSpec = null
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
SearchResultItem(
|
||||||
|
entry = entry,
|
||||||
|
resultIndex = index,
|
||||||
|
onArticleClick = onArticleClick,
|
||||||
|
showDictionaryName = showDictionaryName
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
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.*
|
||||||
@@ -72,6 +79,12 @@ 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()) {
|
||||||
@@ -92,7 +105,21 @@ fun AppNavigation(
|
|||||||
shouldShowSettings -> Screen.Settings
|
shouldShowSettings -> Screen.Settings
|
||||||
else -> currentScreen
|
else -> currentScreen
|
||||||
}
|
}
|
||||||
when (effectiveScreen) {
|
AnimatedContent(
|
||||||
|
targetState = effectiveScreen,
|
||||||
|
transitionSpec = {
|
||||||
|
val forward = initialState.ordinal < targetState.ordinal
|
||||||
|
(
|
||||||
|
fadeIn(tween(180)) +
|
||||||
|
slideInHorizontally(tween(180)) { if (forward) it / 3 else -it / 3 }
|
||||||
|
).togetherWith(
|
||||||
|
fadeOut(tween(180)) +
|
||||||
|
slideOutHorizontally(tween(180)) { if (forward) -it / 3 else it / 3 }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = "screenTransition",
|
||||||
|
) { screen ->
|
||||||
|
when (screen) {
|
||||||
Screen.Home -> {
|
Screen.Home -> {
|
||||||
MainRoute(
|
MainRoute(
|
||||||
searchViewModel = searchViewModel,
|
searchViewModel = searchViewModel,
|
||||||
@@ -142,5 +169,6 @@ fun AppNavigation(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,11 @@ 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
|
||||||
@@ -266,7 +271,11 @@ private fun CollapsibleSettingsSection(
|
|||||||
testTag = testTag
|
testTag = testTag
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isExpanded) {
|
AnimatedVisibility(
|
||||||
|
visible = isExpanded,
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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
|
||||||
@@ -16,6 +17,7 @@ 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
|
||||||
@@ -61,6 +63,7 @@ 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(
|
||||||
@@ -68,7 +71,14 @@ class SettingsViewModel(
|
|||||||
validator = DictionarySourceValidator(),
|
validator = DictionarySourceValidator(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(SettingsUiState(appVersion = appVersion))
|
private val _uiState = MutableStateFlow(
|
||||||
|
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
|
||||||
@@ -91,9 +101,26 @@ 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 ->
|
||||||
@@ -152,7 +179,9 @@ class SettingsViewModel(
|
|||||||
if (wasIndexing && !isIndexing) {
|
if (wasIndexing && !isIndexing) {
|
||||||
if (isDownloadInProgress) {
|
if (isDownloadInProgress) {
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
dictionaryStatus.value = DictionaryStatus.UpToDate
|
dictionaryStatus.value = withContext(Dispatchers.IO) {
|
||||||
|
evaluateDictionaryStatus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wasIndexing = isIndexing
|
wasIndexing = isIndexing
|
||||||
@@ -552,9 +581,21 @@ class SettingsViewModel(
|
|||||||
dictionaryStatus.value = DictionaryStatus.UpToDate
|
dictionaryStatus.value = DictionaryStatus.UpToDate
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
} else {
|
} else {
|
||||||
dictionaryStatus.value = DictionaryStatus.Checking
|
val savedStatus = preferencesManager.dictionaryStatus()
|
||||||
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
val savedStatusName = savedStatus?.substringBefore('|')
|
||||||
dictionaryStatus.value = status
|
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 {
|
||||||
|
dictionaryStatus.value = DictionaryStatus.Checking
|
||||||
|
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
||||||
|
dictionaryStatus.value = status
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.data > 0) {
|
if (result.data > 0) {
|
||||||
|
|||||||
+36
-6
@@ -1,6 +1,14 @@
|
|||||||
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
|
||||||
@@ -15,8 +23,10 @@ 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
|
||||||
@@ -110,7 +120,11 @@ fun DictionaryManagement(
|
|||||||
testTag = "dictionary_header"
|
testTag = "dictionary_header"
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isExpanded) {
|
AnimatedVisibility(
|
||||||
|
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()
|
||||||
@@ -260,18 +274,34 @@ 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) {
|
||||||
DictionaryListItem(
|
val hasAppeared = dictionary.path in appearedPaths
|
||||||
dictionary = dictionary,
|
val rowAlpha by animateFloatAsState(
|
||||||
onToggle = { onToggleDictionary(dictionary.path) },
|
targetValue = if (hasAppeared) 1f else 0f,
|
||||||
onDelete = { onDeleteDictionary(dictionary) },
|
animationSpec = tween(150),
|
||||||
isDeleteBlocked = isDeleteBlocked
|
label = "dictionaryRowAppear",
|
||||||
)
|
)
|
||||||
|
LaunchedEffect(dictionary.path) {
|
||||||
|
appearedPaths = appearedPaths + dictionary.path
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.alpha(rowAlpha)) {
|
||||||
|
DictionaryListItem(
|
||||||
|
dictionary = dictionary,
|
||||||
|
onToggle = { onToggleDictionary(dictionary.path) },
|
||||||
|
onDelete = { onDeleteDictionary(dictionary) },
|
||||||
|
isDeleteBlocked = isDeleteBlocked
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
<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>
|
||||||
@@ -48,6 +47,7 @@
|
|||||||
<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 или .dz</string>
|
<string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .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>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
<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>
|
||||||
@@ -48,6 +47,7 @@
|
|||||||
<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 .dz</string>
|
<string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dsl.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>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
- 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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
- Приложение запускается быстрее
|
||||||
|
- Плавные анимации переходов между экранами
|
||||||
|
- Статьи корректно отображают переносы строк, отступы и цвета из DSL-разметки
|
||||||
|
- Табуляции и лишние пробелы в статьях больше не ломают вёрстку
|
||||||
|
- Заголовок статьи можно скопировать, тап по статье сбрасывает выделение
|
||||||
|
- Адреса источников принимают формат host:port
|
||||||
|
- Загрузка словарей автоматически повторяется при переключении сети
|
||||||
|
- Уведомления о прогрессе загрузки стали надёжнее
|
||||||
|
- Результаты поиска очищаются мгновенно при стирании запроса
|
||||||
|
- Остаточные файлы после отменённых загрузок удаляются автоматически
|
||||||
|
- Повышена стабильность приложения
|
||||||
|
- Ускорена индексация словарей
|
||||||
|
- Обновлены тулинг сборки и документация
|
||||||
|
- Длинные статьи со ссылками больше не подвисают при прокрутке
|
||||||
|
- Большие списки результатов поиска отображаются быстрее
|
||||||
|
- Тёмные цвета статей читаемы в тёмной теме
|
||||||
@@ -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.3.1"
|
agp = "9.4.1"
|
||||||
compose_bom = "2026.08.00"
|
compose_bom = "2026.09.00"
|
||||||
core = "1.19.0"
|
core = "1.19.1"
|
||||||
datastore_preferences = "1.2.1"
|
datastore_preferences = "1.2.1"
|
||||||
documentfile = "1.1.0"
|
documentfile = "1.1.0"
|
||||||
kotlin = "2.4.10"
|
kotlin = "2.4.20"
|
||||||
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"
|
||||||
|
|||||||
Reference in new issue
Block a user