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