Compare commits
21
Commits
main
...
92fc5562db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92fc5562db | ||
|
|
e9706ab12a | ||
|
|
9dac7507ed | ||
|
|
7deabeb02a | ||
|
|
c051ba9585 | ||
|
|
97eeae5876 | ||
|
|
a2079a37a1 | ||
|
|
bd253e3e37 | ||
|
|
4b8fce960e | ||
|
|
05f62a42c6 | ||
|
|
232c1955ec | ||
|
|
a62e3a1e8f | ||
|
|
36d58085d7 | ||
|
|
7dedfad68d | ||
|
|
af147ada50 | ||
|
|
32349e2358 | ||
|
|
c56bb4e2a6 | ||
|
|
6514e81804 | ||
|
|
7c87f20ace | ||
|
|
b5bb605665 | ||
|
|
5c275e16d6 |
No files matched your search
@@ -21,6 +21,7 @@ The app stores dictionaries as DictZip files and builds compact binary indexes f
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Fast headword search across multiple DSL dictionaries.
|
- Fast headword search across multiple DSL dictionaries.
|
||||||
|
- Search history in the empty search state: recent queries with per-entry removal and clear-all.
|
||||||
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
|
- Import local `.dsl`, `.dsl.gz`, and `.dsl.dz` dictionary files.
|
||||||
- Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases.
|
- Download dictionaries from configured URLs, with an optional `{DATE}` placeholder for dated releases.
|
||||||
- Automatic extraction, DictZip compression, and indexing.
|
- Automatic extraction, DictZip compression, and indexing.
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ import com.example.research.feature.search.SearchAction
|
|||||||
import com.example.research.feature.search.SearchViewModel
|
import com.example.research.feature.search.SearchViewModel
|
||||||
import com.example.research.ui.navigation.AppNavigation
|
import com.example.research.ui.navigation.AppNavigation
|
||||||
import com.example.research.ui.settings.SettingsViewModel
|
import com.example.research.ui.settings.SettingsViewModel
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -47,13 +45,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
val app = application as ReSearchApplication
|
val app = application as ReSearchApplication
|
||||||
viewModelFactory {
|
viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SearchViewModel(application, app.localDictionaryRepository, createSavedStateHandle())
|
SearchViewModel(application, app.localDictionaryRepository, app.preferencesManager, createSavedStateHandle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var initialSectionExpansion: SectionExpansionStates? = null
|
|
||||||
|
|
||||||
private val settingsViewModel: SettingsViewModel by viewModels {
|
private val settingsViewModel: SettingsViewModel by viewModels {
|
||||||
val app = application as ReSearchApplication
|
val app = application as ReSearchApplication
|
||||||
val versionName = try {
|
val versionName = try {
|
||||||
@@ -72,9 +68,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
app.dictionaryImportManager,
|
app.dictionaryImportManager,
|
||||||
app.dictionaryProgressStateHolder,
|
app.dictionaryProgressStateHolder,
|
||||||
versionName,
|
versionName,
|
||||||
initialSectionExpansion ?: SectionExpansionStates(
|
app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(
|
||||||
isThemeExpanded = false,
|
isThemeExpanded = false,
|
||||||
isLanguageExpanded = false,
|
isLanguageExpanded = false,
|
||||||
|
isSearchBarExpanded = false,
|
||||||
isDictionariesExpanded = true,
|
isDictionariesExpanded = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -87,19 +84,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
requestNotificationPermissionIfNeeded()
|
requestNotificationPermissionIfNeeded()
|
||||||
|
|
||||||
// Load theme and language synchronously before setContent to prevent flashing
|
|
||||||
val app = application as ReSearchApplication
|
val app = application as ReSearchApplication
|
||||||
val initialTheme = loadInitialTheme(app)
|
|
||||||
val initialLanguage = loadInitialLanguage(app)
|
|
||||||
val initialHadNoDictionaries = loadInitialHadNoDictionaries(app)
|
|
||||||
initialSectionExpansion = loadInitialSectionExpansion(app)
|
|
||||||
|
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
setContent {
|
setContent {
|
||||||
|
val initialLaunch by app.initialLaunch.collectAsStateWithLifecycle()
|
||||||
|
val launch = initialLaunch ?: return@setContent
|
||||||
|
|
||||||
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
// Use loaded values initially, then switch to state values when ready
|
// Use loaded values initially, then switch to state values when ready
|
||||||
val theme = settingsState.theme.takeIf { it != AppTheme.SYSTEM } ?: initialTheme
|
val theme = settingsState.theme.takeIf { it != AppTheme.SYSTEM } ?: launch.theme
|
||||||
val language = settingsState.language.takeIf { it != "system" } ?: initialLanguage
|
val language = settingsState.language.takeIf { it != "system" } ?: launch.language
|
||||||
val darkTheme = when (theme) {
|
val darkTheme = when (theme) {
|
||||||
AppTheme.LIGHT -> false
|
AppTheme.LIGHT -> false
|
||||||
AppTheme.DARK -> true
|
AppTheme.DARK -> true
|
||||||
@@ -115,7 +109,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
AppNavigation(
|
AppNavigation(
|
||||||
searchViewModel = searchViewModel,
|
searchViewModel = searchViewModel,
|
||||||
settingsViewModel = settingsViewModel,
|
settingsViewModel = settingsViewModel,
|
||||||
seedNoDictionaries = initialHadNoDictionaries
|
seedNoDictionaries = launch.seedNoDictionaries
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,69 +132,6 @@ 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 (_: Exception) {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadInitialSectionExpansion(app: ReSearchApplication): SectionExpansionStates {
|
|
||||||
return try {
|
|
||||||
runBlocking {
|
|
||||||
app.preferencesManager.sectionExpansionStates()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("MainActivity", "Failed to load section expansion states: ${e.message}")
|
|
||||||
SectionExpansionStates(
|
|
||||||
isThemeExpanded = false,
|
|
||||||
isLanguageExpanded = false,
|
|
||||||
isDictionariesExpanded = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package com.example.research
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.example.research.common.progress.DictionaryProgressStateHolder
|
import com.example.research.common.progress.DictionaryProgressStateHolder
|
||||||
import com.example.research.common.util.NotificationHelper
|
import com.example.research.common.util.NotificationHelper
|
||||||
|
import com.example.research.core.domain.model.AppTheme
|
||||||
import com.example.research.data.local.preferences.PreferencesManager
|
import com.example.research.data.local.preferences.PreferencesManager
|
||||||
|
import com.example.research.data.local.preferences.SectionExpansionStates
|
||||||
import com.example.research.data.repository.LocalDictionaryRepository
|
import com.example.research.data.repository.LocalDictionaryRepository
|
||||||
import com.example.research.feature.download.DownloadManager
|
import com.example.research.feature.download.DownloadManager
|
||||||
import com.example.research.feature.download.repository.DictionaryRepository
|
import com.example.research.feature.download.repository.DictionaryRepository
|
||||||
@@ -12,10 +14,25 @@ import com.example.research.feature.import.DictionaryImportManager
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
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 kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Protocol
|
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 seedNoDictionaries: Boolean,
|
||||||
|
val sectionExpansion: SectionExpansionStates,
|
||||||
|
)
|
||||||
|
|
||||||
class ReSearchApplication : Application() {
|
class ReSearchApplication : Application() {
|
||||||
|
|
||||||
lateinit var preferencesManager: PreferencesManager
|
lateinit var preferencesManager: PreferencesManager
|
||||||
@@ -43,6 +60,9 @@ class ReSearchApplication : Application() {
|
|||||||
private set
|
private set
|
||||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
private val _initialLaunch = MutableStateFlow<InitialLaunch?>(null)
|
||||||
|
val initialLaunch: StateFlow<InitialLaunch?> = _initialLaunch.asStateFlow()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
@@ -53,6 +73,9 @@ class ReSearchApplication : Application() {
|
|||||||
applicationScope.launch(Dispatchers.IO) {
|
applicationScope.launch(Dispatchers.IO) {
|
||||||
preferencesManager.sanitizeDictionarySources()
|
preferencesManager.sanitizeDictionarySources()
|
||||||
}
|
}
|
||||||
|
applicationScope.launch(Dispatchers.IO) {
|
||||||
|
_initialLaunch.value = loadInitialLaunch()
|
||||||
|
}
|
||||||
localDictionaryRepository = LocalDictionaryRepository(this, preferencesManager = preferencesManager)
|
localDictionaryRepository = LocalDictionaryRepository(this, preferencesManager = preferencesManager)
|
||||||
|
|
||||||
okHttpClient = OkHttpClient.Builder()
|
okHttpClient = OkHttpClient.Builder()
|
||||||
@@ -121,6 +144,24 @@ class ReSearchApplication : Application() {
|
|||||||
downloadManager.onTerminal = dictionaryPipelineCoordinator::onDownloadTerminal
|
downloadManager.onTerminal = dictionaryPipelineCoordinator::onDownloadTerminal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun loadInitialLaunch(): InitialLaunch {
|
||||||
|
val defaultExpansions = SectionExpansionStates(
|
||||||
|
isThemeExpanded = false,
|
||||||
|
isLanguageExpanded = false,
|
||||||
|
isSearchBarExpanded = false,
|
||||||
|
isDictionariesExpanded = true,
|
||||||
|
)
|
||||||
|
return InitialLaunch(
|
||||||
|
theme = runCatching { AppTheme.valueOf(preferencesManager.themeMode.first()) }
|
||||||
|
.getOrDefault(AppTheme.SYSTEM),
|
||||||
|
language = runCatching { preferencesManager.language.first() }.getOrDefault("system"),
|
||||||
|
seedNoDictionaries = runCatching { preferencesManager.hadNoDictionaries.first() }
|
||||||
|
.getOrDefault(true),
|
||||||
|
sectionExpansion = runCatching { preferencesManager.sectionExpansionStates() }
|
||||||
|
.getOrDefault(defaultExpansions),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onTrimMemory(level: Int) {
|
override fun onTrimMemory(level: Int) {
|
||||||
super.onTrimMemory(level)
|
super.onTrimMemory(level)
|
||||||
if (::localDictionaryRepository.isInitialized) {
|
if (::localDictionaryRepository.isInitialized) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ class FileStorageManager {
|
|||||||
private const val DELETE_WAIT_INTERVAL = 50L
|
private const val DELETE_WAIT_INTERVAL = 50L
|
||||||
private const val MAX_DELETE_WAIT_COUNT = 10
|
private const val MAX_DELETE_WAIT_COUNT = 10
|
||||||
private const val DEFAULT_MAX_RETRIES = 5
|
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"
|
private const val TAG = "FileStorageManager"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +89,7 @@ class FileStorageManager {
|
|||||||
Log.e(TAG, "Could not delete existing file: $fileName")
|
Log.e(TAG, "Could not delete existing file: $fileName")
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
waitForDeletion(targetFile)
|
||||||
} else {
|
} else {
|
||||||
return@withContext Uri.fromFile(targetFile)
|
return@withContext Uri.fromFile(targetFile)
|
||||||
}
|
}
|
||||||
@@ -95,7 +98,18 @@ class FileStorageManager {
|
|||||||
if (tempBinFile.exists()) {
|
if (tempBinFile.exists()) {
|
||||||
tempBinFile.delete()
|
tempBinFile.delete()
|
||||||
}
|
}
|
||||||
if (targetFile.createNewFile()) {
|
var created = targetFile.createNewFile()
|
||||||
|
var createAttempt = 0
|
||||||
|
while (!created && createAttempt < MAX_CREATE_RETRIES) {
|
||||||
|
delay(CREATE_RETRY_DELAY.milliseconds)
|
||||||
|
if (targetFile.exists()) {
|
||||||
|
targetFile.delete()
|
||||||
|
waitForDeletion(targetFile)
|
||||||
|
}
|
||||||
|
created = targetFile.createNewFile()
|
||||||
|
createAttempt++
|
||||||
|
}
|
||||||
|
if (created) {
|
||||||
Uri.fromFile(targetFile)
|
Uri.fromFile(targetFile)
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "Failed to create file: $fileName")
|
Log.e(TAG, "Failed to create file: $fileName")
|
||||||
@@ -141,6 +155,15 @@ class FileStorageManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FUSE-backed storage keeps a just-deleted file visible to stat/open for a short window
|
||||||
|
private suspend fun waitForDeletion(file: File) {
|
||||||
|
var waitCount = 0
|
||||||
|
while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
|
||||||
|
delay(DELETE_WAIT_INTERVAL.milliseconds)
|
||||||
|
waitCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun waitForFileDeletion(file: DocumentFile) {
|
private suspend fun waitForFileDeletion(file: DocumentFile) {
|
||||||
var waitCount = 0
|
var waitCount = 0
|
||||||
while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
|
while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
|
||||||
|
|||||||
@@ -59,13 +59,12 @@ class NotificationHelper(private val context: Context) : NotificationPort {
|
|||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||||
manager?.createNotificationChannel(channel)
|
manager?.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
private fun canShowNotification(): Boolean {
|
private fun canShowNotification(): Boolean =
|
||||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) return true
|
android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU ||
|
||||||
return ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
context,
|
context,
|
||||||
android.Manifest.permission.POST_NOTIFICATIONS
|
android.Manifest.permission.POST_NOTIFICATIONS
|
||||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
}
|
|
||||||
override fun showProgress(percent: Int, titleRes: Int) {
|
override fun showProgress(percent: Int, titleRes: Int) {
|
||||||
showUnifiedProgressNotification(
|
showUnifiedProgressNotification(
|
||||||
title = context.getString(titleRes),
|
title = context.getString(titleRes),
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ data class DictionarySource(
|
|||||||
return when {
|
return when {
|
||||||
trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY)
|
trimmed.isBlank() -> ValidationResult.Error(ValidationError.EMPTY)
|
||||||
trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG)
|
trimmed.length > 2048 -> ValidationResult.Error(ValidationError.TOO_LONG)
|
||||||
!trimmed.startsWith("http://") && !trimmed.startsWith("https://") ->
|
!trimmed.startsWith("https://") ->
|
||||||
ValidationResult.Error(ValidationError.INVALID_PROTOCOL)
|
ValidationResult.Error(ValidationError.INVALID_PROTOCOL)
|
||||||
!lowerTemplate.endsWith(".gz") &&
|
!lowerTemplate.endsWith(".gz") &&
|
||||||
!lowerTemplate.endsWith(".dsl") &&
|
!lowerTemplate.endsWith(".dsl") &&
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
|
|||||||
data class SectionExpansionStates(
|
data class SectionExpansionStates(
|
||||||
val isThemeExpanded: Boolean,
|
val isThemeExpanded: Boolean,
|
||||||
val isLanguageExpanded: Boolean,
|
val isLanguageExpanded: Boolean,
|
||||||
|
val isSearchBarExpanded: Boolean,
|
||||||
val isDictionariesExpanded: Boolean,
|
val isDictionariesExpanded: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,12 +29,19 @@ class PreferencesManager(private val context: Context) {
|
|||||||
val THEME_MODE = stringPreferencesKey("theme_mode")
|
val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||||
val DICTIONARY_SOURCES = stringPreferencesKey("dictionary_sources")
|
val DICTIONARY_SOURCES = stringPreferencesKey("dictionary_sources")
|
||||||
val LANGUAGE = stringPreferencesKey("language")
|
val LANGUAGE = stringPreferencesKey("language")
|
||||||
|
val SEARCH_BAR_POSITION = stringPreferencesKey("search_bar_position")
|
||||||
val IS_THEME_EXPANDED = booleanPreferencesKey("is_theme_expanded")
|
val IS_THEME_EXPANDED = booleanPreferencesKey("is_theme_expanded")
|
||||||
val IS_LANGUAGE_EXPANDED = booleanPreferencesKey("is_language_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 IS_DICTIONARIES_EXPANDED = booleanPreferencesKey("is_dictionaries_expanded")
|
||||||
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
|
val DISABLED_DICTIONARY_PATHS = stringSetPreferencesKey("disabled_dictionary_paths")
|
||||||
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
|
val HAD_NO_DICTIONARIES = booleanPreferencesKey("had_no_dictionaries")
|
||||||
val DICTIONARY_STATUS = stringPreferencesKey("dictionary_status")
|
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 }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
@@ -57,6 +65,17 @@ class PreferencesManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val searchBarPosition: Flow<String> = context.dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
preferences[PreferencesKeys.SEARCH_BAR_POSITION] ?: "TOP"
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveSearchBarPosition(position: String) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[PreferencesKeys.SEARCH_BAR_POSITION] = position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val dictionarySources: Flow<List<DictionarySource>> = context.dataStore.data
|
val dictionarySources: Flow<List<DictionarySource>> = context.dataStore.data
|
||||||
.map { preferences ->
|
.map { preferences ->
|
||||||
val sourcesJson = preferences[PreferencesKeys.DICTIONARY_SOURCES]
|
val sourcesJson = preferences[PreferencesKeys.DICTIONARY_SOURCES]
|
||||||
@@ -101,6 +120,7 @@ class PreferencesManager(private val context: Context) {
|
|||||||
SectionExpansionStates(
|
SectionExpansionStates(
|
||||||
isThemeExpanded = preferences[PreferencesKeys.IS_THEME_EXPANDED] ?: false,
|
isThemeExpanded = preferences[PreferencesKeys.IS_THEME_EXPANDED] ?: false,
|
||||||
isLanguageExpanded = preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false,
|
isLanguageExpanded = preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false,
|
||||||
|
isSearchBarExpanded = preferences[PreferencesKeys.IS_SEARCH_BAR_EXPANDED] ?: false,
|
||||||
isDictionariesExpanded = preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true,
|
isDictionariesExpanded = preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -143,6 +163,11 @@ class PreferencesManager(private val context: Context) {
|
|||||||
preferences[PreferencesKeys.IS_LANGUAGE_EXPANDED] ?: false
|
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
|
val isDictionariesExpanded: Flow<Boolean> = context.dataStore.data
|
||||||
.map { preferences ->
|
.map { preferences ->
|
||||||
preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true
|
preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] ?: true
|
||||||
@@ -153,6 +178,7 @@ class PreferencesManager(private val context: Context) {
|
|||||||
when (key) {
|
when (key) {
|
||||||
"theme" -> preferences[PreferencesKeys.IS_THEME_EXPANDED] = isExpanded
|
"theme" -> preferences[PreferencesKeys.IS_THEME_EXPANDED] = isExpanded
|
||||||
"language" -> preferences[PreferencesKeys.IS_LANGUAGE_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
|
"dictionaries" -> preferences[PreferencesKeys.IS_DICTIONARIES_EXPANDED] = isExpanded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,6 +225,51 @@ class PreferencesManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val searchHistory: Flow<List<String>> = context.dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
suspend fun sanitizeDictionarySources() {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
val currentSources = decodeSources(
|
val currentSources = decodeSources(
|
||||||
|
|||||||
@@ -454,11 +454,7 @@ class IndexSearcher(private val context: Context) {
|
|||||||
val originalWord = readPooledString(source, source.readUnsignedShort())
|
val originalWord = readPooledString(source, source.readUnsignedShort())
|
||||||
val offset = ArticleOffset(source.readLong())
|
val offset = ArticleOffset(source.readLong())
|
||||||
val length = ArticleLength(source.readInt())
|
val length = ArticleLength(source.readInt())
|
||||||
val isAlias = if (metadata.version >= 6) {
|
val isAlias = metadata.version >= 6 && source.readUnsignedByte() and ENTRY_FLAG_ALIAS != 0
|
||||||
source.readUnsignedByte() and ENTRY_FLAG_ALIAS != 0
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
return IndexEntry(word, originalWord, offset, length, isAlias = isAlias)
|
return IndexEntry(word, originalWord, offset, length, isAlias = isAlias)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,8 +468,11 @@ class IndexSearcher(private val context: Context) {
|
|||||||
|
|
||||||
private fun openSource(pathOrUri: String): RandomAccessSource {
|
private fun openSource(pathOrUri: String): RandomAccessSource {
|
||||||
val file = File(pathOrUri)
|
val file = File(pathOrUri)
|
||||||
if (!file.exists()) throw DictionaryError.FileSystemError.NotFound(pathOrUri)
|
return if (file.exists()) {
|
||||||
return FileSource(file)
|
FileSource(file)
|
||||||
|
} else {
|
||||||
|
throw DictionaryError.FileSystemError.NotFound(pathOrUri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private fun openInputStream(pathOrUri: String): java.io.InputStream {
|
private fun openInputStream(pathOrUri: String): java.io.InputStream {
|
||||||
val file = File(pathOrUri)
|
val file = File(pathOrUri)
|
||||||
|
|||||||
@@ -112,20 +112,20 @@ class KotlinDictionaryEngine(private val context: Context) : ProgressIndexingEng
|
|||||||
maxResults: Int
|
maxResults: Int
|
||||||
): List<IndexEntry> = withContext(Dispatchers.IO) {
|
): List<IndexEntry> = withContext(Dispatchers.IO) {
|
||||||
val allResults = mutableListOf<IndexEntry>()
|
val allResults = mutableListOf<IndexEntry>()
|
||||||
for (dict in dictionaries) {
|
for ((name, path, indexPath, _, _, metadata) in dictionaries) {
|
||||||
try {
|
try {
|
||||||
val indexPath = resolveIndexPath(dict.indexPath)
|
val resolvedIndexPath = resolveIndexPath(indexPath)
|
||||||
val entry = indexSearcher.findFirstEntry(indexPath, query)
|
val entry = indexSearcher.findFirstEntry(resolvedIndexPath, query)
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
allResults.add(entry.withDictionary(dict.metadata.name.ifBlank { dict.name }, dict.path))
|
allResults.add(entry.withDictionary(metadata.name.ifBlank { name }, path))
|
||||||
}
|
}
|
||||||
if (allResults.size >= maxResults) break
|
if (allResults.size >= maxResults) break
|
||||||
} catch (e: OutOfMemoryError) {
|
} catch (e: OutOfMemoryError) {
|
||||||
Log.e(TAG, "search() - OOM for ${dict.indexPath}", e)
|
Log.e(TAG, "search() - OOM for $indexPath", e)
|
||||||
trimMemory(80)
|
trimMemory(80)
|
||||||
break
|
break
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "search() - error for ${dict.indexPath}: ${e.message}")
|
Log.w(TAG, "search() - error for $indexPath: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
allResults
|
allResults
|
||||||
|
|||||||
@@ -538,12 +538,12 @@ class LocalDictionaryRepository(
|
|||||||
val dictionaries = mutableDictionaries.value
|
val dictionaries = mutableDictionaries.value
|
||||||
if (dictionaries.isEmpty()) return@withContext
|
if (dictionaries.isEmpty()) return@withContext
|
||||||
|
|
||||||
for (dict in dictionaries) {
|
for ((_, _, indexPath) in dictionaries) {
|
||||||
try {
|
try {
|
||||||
val indexPath = resolveIndexPath(dict.indexPath)
|
val resolvedIndexPath = resolveIndexPath(indexPath)
|
||||||
val indexSearcher = engine.getIndexSearcher()
|
val indexSearcher = engine.getIndexSearcher()
|
||||||
if (!indexSearcher.isMetadataLoaded(indexPath)) {
|
if (!indexSearcher.isMetadataLoaded(resolvedIndexPath)) {
|
||||||
engine.preloadIndexMetadata(indexPath)
|
engine.preloadIndexMetadata(resolvedIndexPath)
|
||||||
}
|
}
|
||||||
yield()
|
yield()
|
||||||
} catch (_: Exception) { /* Ignore preload failure */ }
|
} catch (_: Exception) { /* Ignore preload failure */ }
|
||||||
|
|||||||
+4
-1
@@ -7,6 +7,7 @@ import com.example.research.common.util.FileStorageManager
|
|||||||
import com.example.research.common.util.DocumentFileInfo
|
import com.example.research.common.util.DocumentFileInfo
|
||||||
import com.example.research.core.domain.model.DictionarySource
|
import com.example.research.core.domain.model.DictionarySource
|
||||||
import com.example.research.feature.download.config.DictionaryConfig
|
import com.example.research.feature.download.config.DictionaryConfig
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -101,9 +102,11 @@ class DictionaryChecker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return@withContext !serverCanOfferNewer
|
return@withContext !serverCanOfferNewer
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error checking currency: ${e.message}", e)
|
Log.e(TAG, "Error checking currency: ${e.message}", e)
|
||||||
false
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -54,17 +54,17 @@ class DictionaryCleaner(
|
|||||||
for ((prefix, items) in parsedByPrefix) {
|
for ((prefix, items) in parsedByPrefix) {
|
||||||
val latestDsl = latestDslDateByPrefix[prefix] ?: continue
|
val latestDsl = latestDslDateByPrefix[prefix] ?: continue
|
||||||
val dslDates = dslDatesByPrefix[prefix].orEmpty()
|
val dslDates = dslDatesByPrefix[prefix].orEmpty()
|
||||||
for (item in items) {
|
for ((name, _, date, ext) in items) {
|
||||||
if (DateUtils.isDateBefore(item.date, latestDsl)) {
|
if (DateUtils.isDateBefore(date, latestDsl)) {
|
||||||
toDelete.add(item.name)
|
toDelete.add(name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (item.ext in indexExtensions && item.date != latestDsl) {
|
if (ext in indexExtensions && date != latestDsl) {
|
||||||
toDelete.add(item.name)
|
toDelete.add(name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (item.ext in indexExtensions && item.date !in dslDates) {
|
if (ext in indexExtensions && date !in dslDates) {
|
||||||
toDelete.add(item.name)
|
toDelete.add(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-21
@@ -46,14 +46,13 @@ class DictionaryExtractor(
|
|||||||
reportProgress(processedIndex.toFloat() / totalFiles)
|
reportProgress(processedIndex.toFloat() / totalFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (file in gzFiles) {
|
for ((fileName, uri) in gzFiles) {
|
||||||
val fileName = file.name
|
|
||||||
val baseName = fileName.dropLast(DictionaryConfig.GZ_EXTENSION.length)
|
val baseName = fileName.dropLast(DictionaryConfig.GZ_EXTENSION.length)
|
||||||
val lowerBaseName = baseName.lowercase()
|
val lowerBaseName = baseName.lowercase()
|
||||||
if (baseName.isBlank() ||
|
if (baseName.isBlank() ||
|
||||||
lowerBaseName.endsWith(DictionaryConfig.GZ_EXTENSION) ||
|
lowerBaseName.endsWith(DictionaryConfig.GZ_EXTENSION) ||
|
||||||
lowerBaseName.endsWith(".txt")) {
|
lowerBaseName.endsWith(".txt")) {
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
markFileProcessed()
|
markFileProcessed()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -65,19 +64,19 @@ class DictionaryExtractor(
|
|||||||
val dslFileName = "$dslBaseName${DictionaryConfig.DSL_EXTENSION}"
|
val dslFileName = "$dslBaseName${DictionaryConfig.DSL_EXTENSION}"
|
||||||
val existingDslFile = fileStorageManager.findFile(folderUri, dslFileName)
|
val existingDslFile = fileStorageManager.findFile(folderUri, dslFileName)
|
||||||
if (existingDslFile != null && existingDslFile.exists()) {
|
if (existingDslFile != null && existingDslFile.exists()) {
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
markFileProcessed()
|
markFileProcessed()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val dslUri = fileStorageManager.createFileInFolder(folderUri, dslFileName, overwrite = false)
|
val dslUri = fileStorageManager.createFileInFolder(folderUri, dslFileName, overwrite = false)
|
||||||
?: continue
|
?: continue
|
||||||
val extractResult = ArchiveUtils.extractGzWithBom(context, file.uri, dslUri)
|
val extractResult = ArchiveUtils.extractGzWithBom(context, uri, dslUri)
|
||||||
if (extractResult.isSuccess) {
|
if (extractResult.isSuccess) {
|
||||||
val extractedFile = fileStorageManager.getDocumentFile(dslUri)
|
val extractedFile = fileStorageManager.getDocumentFile(dslUri)
|
||||||
if (extractedFile != null && extractedFile.exists()) {
|
if (extractedFile != null && extractedFile.exists()) {
|
||||||
if (extractedFile.length() == 0L) {
|
if (extractedFile.length() == 0L) {
|
||||||
ArchiveUtils.deleteFile(context, dslUri)
|
ArchiveUtils.deleteFile(context, dslUri)
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
markFileProcessed()
|
markFileProcessed()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -92,7 +91,7 @@ class DictionaryExtractor(
|
|||||||
ArchiveUtils.deleteFile(context, dslUri)
|
ArchiveUtils.deleteFile(context, dslUri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
} else {
|
} else {
|
||||||
Log.e("DictionaryExtractor", "File extraction error: $fileName")
|
Log.e("DictionaryExtractor", "File extraction error: $fileName")
|
||||||
ArchiveUtils.deleteFile(context, dslUri)
|
ArchiveUtils.deleteFile(context, dslUri)
|
||||||
@@ -100,12 +99,11 @@ class DictionaryExtractor(
|
|||||||
markFileProcessed()
|
markFileProcessed()
|
||||||
}
|
}
|
||||||
|
|
||||||
for (file in rawDslFiles) {
|
for ((dslFileName, uri) in rawDslFiles) {
|
||||||
val dslFileName = file.name
|
|
||||||
val dzFileName = "$dslFileName.dz"
|
val dzFileName = "$dslFileName.dz"
|
||||||
val existingDz = fileStorageManager.findFile(folderUri, dzFileName)
|
val existingDz = fileStorageManager.findFile(folderUri, dzFileName)
|
||||||
if (existingDz != null && existingDz.exists()) {
|
if (existingDz != null && existingDz.exists()) {
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
markFileProcessed()
|
markFileProcessed()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -113,11 +111,11 @@ class DictionaryExtractor(
|
|||||||
val fileBaseProgress = processedIndex.toFloat() / totalFiles
|
val fileBaseProgress = processedIndex.toFloat() / totalFiles
|
||||||
val fileProgressWeight = 1f / totalFiles
|
val fileProgressWeight = 1f / totalFiles
|
||||||
reportProgress(fileBaseProgress)
|
reportProgress(fileBaseProgress)
|
||||||
val compressResult = ensureDslDictZip(file.uri, dslFileName, folderUri) { compressProgress ->
|
val compressResult = ensureDslDictZip(uri, dslFileName, folderUri) { compressProgress ->
|
||||||
reportProgress(fileBaseProgress + (compressProgress * fileProgressWeight))
|
reportProgress(fileBaseProgress + (compressProgress * fileProgressWeight))
|
||||||
}
|
}
|
||||||
if (compressResult.isSuccess) {
|
if (compressResult.isSuccess) {
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
}
|
}
|
||||||
markFileProcessed()
|
markFileProcessed()
|
||||||
}
|
}
|
||||||
@@ -130,11 +128,10 @@ class DictionaryExtractor(
|
|||||||
!lowerName.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
|
!lowerName.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
|
||||||
!lowerName.endsWith(".dsl.gz")
|
!lowerName.endsWith(".dsl.gz")
|
||||||
}
|
}
|
||||||
for (file in remainingRawDslFiles) {
|
for ((dslFileName, uri) in remainingRawDslFiles) {
|
||||||
val dslFileName = file.name
|
val compressResult = ensureDslDictZip(uri, dslFileName, folderUri, onProgress = null)
|
||||||
val compressResult = ensureDslDictZip(file.uri, dslFileName, folderUri, onProgress = null)
|
|
||||||
if (compressResult.isSuccess) {
|
if (compressResult.isSuccess) {
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,18 +149,18 @@ class DictionaryExtractor(
|
|||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO + NonCancellable) {
|
withContext(Dispatchers.IO + NonCancellable) {
|
||||||
val files = fileStorageManager.listFilesInFolder(folderUri)
|
val files = fileStorageManager.listFilesInFolder(folderUri)
|
||||||
for (file in files) {
|
for ((name, uri) in files) {
|
||||||
val n = file.name.lowercase()
|
val n = name.lowercase()
|
||||||
val isRawDsl = n.endsWith(DictionaryConfig.DSL_EXTENSION) &&
|
val isRawDsl = n.endsWith(DictionaryConfig.DSL_EXTENSION) &&
|
||||||
!n.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
|
!n.endsWith(DictionaryConfig.DICTZIP_EXTENSION) &&
|
||||||
!n.endsWith(".dsl.gz")
|
!n.endsWith(".dsl.gz")
|
||||||
val isBareDz = n.endsWith(".dz") && !n.endsWith(DictionaryConfig.DICTZIP_EXTENSION)
|
val isBareDz = n.endsWith(".dz") && !n.endsWith(DictionaryConfig.DICTZIP_EXTENSION)
|
||||||
when {
|
when {
|
||||||
isBareDz -> ArchiveUtils.deleteFile(context, file.uri)
|
isBareDz -> ArchiveUtils.deleteFile(context, uri)
|
||||||
isRawDsl -> {
|
isRawDsl -> {
|
||||||
val pair = fileStorageManager.findFile(folderUri, file.name + ".dz")
|
val pair = fileStorageManager.findFile(folderUri, "$name.dz")
|
||||||
if (pair == null || !pair.exists()) {
|
if (pair == null || !pair.exists()) {
|
||||||
ArchiveUtils.deleteFile(context, file.uri)
|
ArchiveUtils.deleteFile(context, uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -144,9 +144,8 @@ class DictionaryRepository(
|
|||||||
if (filesToDelete.isNotEmpty()) {
|
if (filesToDelete.isNotEmpty()) {
|
||||||
var deletedCount = 0
|
var deletedCount = 0
|
||||||
|
|
||||||
for (file in filesToDelete) {
|
for ((fileName, uri) in filesToDelete) {
|
||||||
val fileName = file.name
|
val docFile = fileStorageManager.getDocumentFile(uri)
|
||||||
val docFile = fileStorageManager.getDocumentFile(file.uri)
|
|
||||||
if (docFile != null && docFile.exists()) {
|
if (docFile != null && docFile.exists()) {
|
||||||
val deleted = docFile.delete()
|
val deleted = docFile.delete()
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.example.research.core.util.onError
|
|||||||
import com.example.research.core.util.onSuccess
|
import com.example.research.core.util.onSuccess
|
||||||
import com.example.research.core.util.sanitizeQuery
|
import com.example.research.core.util.sanitizeQuery
|
||||||
import com.example.research.data.paging.IndexEntryPagingSource
|
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.data.repository.LocalDictionaryRepository
|
||||||
import com.example.research.ui.article.ArticleState
|
import com.example.research.ui.article.ArticleState
|
||||||
import com.example.research.ui.article.ArticleTextWarmUp
|
import com.example.research.ui.article.ArticleTextWarmUp
|
||||||
@@ -34,6 +35,7 @@ sealed interface SearchAction {
|
|||||||
class SearchViewModel(
|
class SearchViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val repository: LocalDictionaryRepository,
|
private val repository: LocalDictionaryRepository,
|
||||||
|
private val preferencesManager: PreferencesManager,
|
||||||
private val savedStateHandle: SavedStateHandle
|
private val savedStateHandle: SavedStateHandle
|
||||||
) : AndroidViewModel(application) {
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
@@ -51,6 +53,13 @@ class SearchViewModel(
|
|||||||
private val mutableErrorMessage = MutableStateFlow<String?>(null)
|
private val mutableErrorMessage = MutableStateFlow<String?>(null)
|
||||||
val errorMessage: StateFlow<String?> = mutableErrorMessage.asStateFlow()
|
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 val indexSearcher by lazy { repository.getIndexSearcher() }
|
||||||
private var indexWarmupJob: Job? = null
|
private var indexWarmupJob: Job? = null
|
||||||
private var debounceSaveQueryJob: Job? = null
|
private var debounceSaveQueryJob: Job? = null
|
||||||
@@ -61,10 +70,12 @@ class SearchViewModel(
|
|||||||
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
|
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
|
||||||
repository.dictionaries.map { dictList ->
|
repository.dictionaries.map { dictList ->
|
||||||
dictList.filter { it.isActive }
|
dictList.filter { it.isActive }
|
||||||
|
}.distinctUntilChanged { previous, current ->
|
||||||
|
previous.map { it.indexPath } == current.map { it.indexPath }
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
initialValue = emptyList()
|
initialValue = repository.dictionaries.value.filter { it.isActive }
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -126,6 +137,7 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
is SearchAction.ArticleClicked -> {
|
is SearchAction.ArticleClicked -> {
|
||||||
articleHistory.clear()
|
articleHistory.clear()
|
||||||
|
recordSearchHistory(action.entry.originalWord)
|
||||||
loadArticle(action.entry)
|
loadArticle(action.entry)
|
||||||
}
|
}
|
||||||
SearchAction.ClearArticle -> clearArticle()
|
SearchAction.ClearArticle -> clearArticle()
|
||||||
@@ -159,6 +171,26 @@ class SearchViewModel(
|
|||||||
mutableErrorMessage.value = null
|
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
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesManager.addSearchHistoryEntry(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun saveScrollPosition(index: Int, offset: Int) {
|
fun saveScrollPosition(index: Int, offset: Int) {
|
||||||
mutableScrollPosition.value = index to offset
|
mutableScrollPosition.value = index to offset
|
||||||
}
|
}
|
||||||
@@ -281,6 +313,7 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
entry?.let {
|
entry?.let {
|
||||||
currentArticle?.let(articleHistory::addLast)
|
currentArticle?.let(articleHistory::addLast)
|
||||||
|
recordSearchHistory(sanitizedWord)
|
||||||
loadArticle(it)
|
loadArticle(it)
|
||||||
}
|
}
|
||||||
}.onError { message, exception ->
|
}.onError { message, exception ->
|
||||||
@@ -290,11 +323,7 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun handleExternalSearch(text: String) {
|
fun handleExternalSearch(text: String) {
|
||||||
viewModelScope.launch {
|
onSearchQueryChanged(text)
|
||||||
val sanitizedText = text.sanitizeQuery()
|
|
||||||
mutableSearchQuery.value = sanitizedText
|
|
||||||
searchAndLoadArticle(sanitizedText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package com.example.research.ui.about
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.annotation.RawRes
|
import androidx.annotation.RawRes
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
internal data class AboutLibrariesData(
|
internal data class AboutLibrariesData(
|
||||||
@@ -74,8 +76,8 @@ internal object AboutLibrariesParser {
|
|||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
fun read(context: Context, @RawRes resourceId: Int): AboutLibrariesData =
|
fun read(context: Context, @RawRes resourceId: Int): AboutLibrariesData =
|
||||||
context.resources.openRawResource(resourceId)
|
context.resources.openRawResource(resourceId)
|
||||||
.bufferedReader()
|
.use { stream -> json.decodeFromStream(stream) }
|
||||||
.use { reader -> json.decodeFromString(reader.readText()) }
|
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -21,8 +20,6 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.AlertDialogDefaults
|
import androidx.compose.material3.AlertDialogDefaults
|
||||||
import androidx.compose.material3.BasicAlertDialog
|
import androidx.compose.material3.BasicAlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -46,20 +43,20 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.platform.LocalWindowInfo
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
import com.example.research.common.ui.AppBackButton
|
||||||
import com.example.research.common.ui.components.OutlinedChoiceButton
|
import com.example.research.common.ui.components.OutlinedChoiceButton
|
||||||
import com.example.research.ui.theme.AppWindowInsets
|
import com.example.research.ui.theme.AppWindowInsets
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AboutScreen(
|
fun AboutScreen(
|
||||||
onNavigateBack: () -> Unit
|
onNavigateBack: () -> Unit
|
||||||
@@ -94,12 +91,10 @@ fun AboutScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.about_app_title)) },
|
title = { Text(stringResource(R.string.about_app_title)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onNavigateBack) {
|
AppBackButton(
|
||||||
Icon(
|
onClick = onNavigateBack,
|
||||||
painter = painterResource(R.drawable.ic_arrow_back),
|
modifier = Modifier.padding(start = 6.dp)
|
||||||
contentDescription = stringResource(R.string.action_back)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
@@ -186,7 +181,6 @@ private fun AboutHeader(versionName: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutLibraryRow(
|
private fun AboutLibraryRow(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|||||||
@@ -196,9 +196,7 @@ private suspend fun PointerInputScope.detectReferenceTaps(
|
|||||||
if (change == null) {
|
if (change == null) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (change.isConsumed ||
|
if ((change.position - downPosition).getDistance() > viewConfiguration.touchSlop) {
|
||||||
(change.position - downPosition).getDistance() > viewConfiguration.touchSlop
|
|
||||||
) {
|
|
||||||
canceled = true
|
canceled = true
|
||||||
}
|
}
|
||||||
if (!change.pressed) {
|
if (!change.pressed) {
|
||||||
|
|||||||
@@ -2,33 +2,58 @@ package com.example.research.ui.article
|
|||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
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.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.runtime.*
|
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.runtime.saveable.rememberSaveable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.style.LineHeightStyle
|
import androidx.compose.ui.text.style.LineHeightStyle
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
import com.example.research.common.ui.AppBackButton
|
||||||
import com.example.research.common.ui.theme.dictionaryTitleLarge
|
import com.example.research.common.ui.theme.dictionaryTitleLarge
|
||||||
import com.example.research.ui.theme.Spacing
|
|
||||||
import com.example.research.ui.theme.AppWindowInsets
|
import com.example.research.ui.theme.AppWindowInsets
|
||||||
|
import com.example.research.ui.theme.Spacing
|
||||||
|
|
||||||
private const val MERGED_BLOCK_MAX_CHARS = 1500
|
private const val MERGED_BLOCK_MAX_CHARS = 1500
|
||||||
private const val MERGED_BLOCK_MAX_ITEMS = 12
|
private const val MERGED_BLOCK_MAX_ITEMS = 12
|
||||||
@@ -92,13 +117,17 @@ fun ArticleContent(
|
|||||||
}
|
}
|
||||||
is ArticleState.Success -> {
|
is ArticleState.Success -> {
|
||||||
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
|
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
|
||||||
val baseTextStyle = MaterialTheme.typography.bodyLarge.copy(
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
val typography = MaterialTheme.typography
|
||||||
lineHeightStyle = LineHeightStyle(
|
val baseTextStyle = remember(colorScheme, typography) {
|
||||||
alignment = LineHeightStyle.Alignment.Proportional,
|
typography.bodyLarge.copy(
|
||||||
trim = LineHeightStyle.Trim.Both,
|
color = colorScheme.onSurface,
|
||||||
),
|
lineHeightStyle = LineHeightStyle(
|
||||||
)
|
alignment = LineHeightStyle.Alignment.Proportional,
|
||||||
|
trim = LineHeightStyle.Trim.Both,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
val indentStep = 16.dp
|
val indentStep = 16.dp
|
||||||
|
|
||||||
// Use scroll position from ArticleState if available (for back navigation)
|
// Use scroll position from ArticleState if available (for back navigation)
|
||||||
@@ -263,7 +292,14 @@ fun ArticleTitleBar(
|
|||||||
null -> ""
|
null -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
val backIcon = painterResource(R.drawable.ic_arrow_back)
|
val backButtonOffset = if (compact) {
|
||||||
|
val titleLineHeight = with(LocalDensity.current) {
|
||||||
|
MaterialTheme.typography.dictionaryTitleLarge.lineHeight.toDp()
|
||||||
|
}
|
||||||
|
Modifier.offset(y = (titleLineHeight - 48.dp) / 2)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
@@ -282,22 +318,15 @@ fun ArticleTitleBar(
|
|||||||
.height(Spacing.topBarHeight)
|
.height(Spacing.topBarHeight)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = if (compact) Alignment.Top else Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
if (showBackButton) {
|
if (showBackButton) {
|
||||||
Box(
|
AppBackButton(
|
||||||
|
onClick = onNavigateBack,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = Spacing.iconStart)
|
.padding(start = 10.dp)
|
||||||
.size(24.dp)
|
.then(backButtonOffset)
|
||||||
.clickable(onClick = onNavigateBack),
|
)
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = backIcon,
|
|
||||||
contentDescription = stringResource(R.string.action_back),
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
key(selectionReset) {
|
key(selectionReset) {
|
||||||
@@ -309,7 +338,7 @@ fun ArticleTitleBar(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = if (showBackButton) Spacing.searchTextStart - Spacing.iconStart - 24.dp else 16.dp)
|
.padding(start = if (showBackButton) 0.dp else 16.dp)
|
||||||
.testTag("article_title")
|
.testTag("article_title")
|
||||||
.semantics { testTagsAsResourceId = true }
|
.semantics { testTagsAsResourceId = true }
|
||||||
)
|
)
|
||||||
@@ -319,7 +348,6 @@ fun ArticleTitleBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ArticleScreen(
|
internal fun ArticleScreen(
|
||||||
articleState: ArticleState?,
|
articleState: ArticleState?,
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ fun AdaptiveMainScreen(
|
|||||||
searchState: TextFieldState,
|
searchState: TextFieldState,
|
||||||
canSearch: Boolean,
|
canSearch: Boolean,
|
||||||
isLandscape: Boolean,
|
isLandscape: Boolean,
|
||||||
|
isSearchBarAtBottom: Boolean,
|
||||||
results: LazyPagingItems<IndexEntry>,
|
results: LazyPagingItems<IndexEntry>,
|
||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
showDictionaryName: Boolean,
|
showDictionaryName: Boolean,
|
||||||
|
searchHistory: List<String>,
|
||||||
articleState: ArticleState?,
|
articleState: ArticleState?,
|
||||||
scrollPosition: Pair<Int, Int>,
|
scrollPosition: Pair<Int, Int>,
|
||||||
onAction: (MainAction) -> Unit,
|
onAction: (MainAction) -> Unit,
|
||||||
@@ -46,21 +48,41 @@ fun AdaptiveMainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isQueryEmpty = searchState.text.isEmpty()
|
|
||||||
val isShowingDetail = scaffoldState.targetState.primary == PaneAdaptedValue.Expanded
|
val isShowingDetail = scaffoldState.targetState.primary == PaneAdaptedValue.Expanded
|
||||||
val isTwoPane =
|
val isTwoPane =
|
||||||
scaffoldState.targetState.primary == PaneAdaptedValue.Expanded &&
|
scaffoldState.targetState.primary == PaneAdaptedValue.Expanded &&
|
||||||
scaffoldState.targetState.secondary == PaneAdaptedValue.Expanded
|
scaffoldState.targetState.secondary == PaneAdaptedValue.Expanded
|
||||||
val listEndPadding = if (isTwoPane) 8.dp else 16.dp
|
val listEndPadding = if (isTwoPane) 8.dp else 16.dp
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
val searchBar: @Composable () -> Unit = {
|
||||||
MainTopBar(
|
MainTopBar(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
enabled = canSearch,
|
enabled = canSearch,
|
||||||
|
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||||
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
||||||
onClearQuery = { onAction(MainAction.ClearQuery) },
|
onClearQuery = { onAction(MainAction.ClearQuery) },
|
||||||
|
searchHistory = searchHistory,
|
||||||
|
onHistoryEntryClick = { onAction(MainAction.HistoryEntryClicked(it)) },
|
||||||
|
onHistoryEntryRemove = { onAction(MainAction.RemoveHistoryEntry(it)) },
|
||||||
|
onClearHistory = { onAction(MainAction.ClearHistory) },
|
||||||
onNavigateToSettings = onNavigateToSettings
|
onNavigateToSettings = onNavigateToSettings
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(
|
||||||
|
if (isSearchBarAtBottom) {
|
||||||
|
Modifier.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.ime))
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (!isSearchBarAtBottom) {
|
||||||
|
searchBar()
|
||||||
|
}
|
||||||
|
|
||||||
ListDetailPaneScaffold(
|
ListDetailPaneScaffold(
|
||||||
directive = PaneScaffoldDirective.Default.copy(
|
directive = PaneScaffoldDirective.Default.copy(
|
||||||
@@ -120,26 +142,21 @@ fun AdaptiveMainScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (isSearchBarAtBottom) {
|
||||||
|
searchBar()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(articleState, isQueryEmpty, isLandscape) {
|
LaunchedEffect(articleState) {
|
||||||
val targetValue = when {
|
val targetValue = if (articleState == null) {
|
||||||
isLandscape -> ThreePaneScaffoldValue(
|
ThreePaneScaffoldValue(
|
||||||
primary = PaneAdaptedValue.Expanded,
|
|
||||||
secondary = PaneAdaptedValue.Expanded,
|
|
||||||
tertiary = PaneAdaptedValue.Hidden,
|
|
||||||
)
|
|
||||||
articleState == null -> ThreePaneScaffoldValue(
|
|
||||||
primary = PaneAdaptedValue.Hidden,
|
primary = PaneAdaptedValue.Hidden,
|
||||||
secondary = PaneAdaptedValue.Expanded,
|
secondary = PaneAdaptedValue.Expanded,
|
||||||
tertiary = PaneAdaptedValue.Hidden,
|
tertiary = PaneAdaptedValue.Hidden,
|
||||||
)
|
)
|
||||||
isQueryEmpty -> ThreePaneScaffoldValue(
|
} else {
|
||||||
primary = PaneAdaptedValue.Expanded,
|
ThreePaneScaffoldValue(
|
||||||
secondary = PaneAdaptedValue.Hidden,
|
|
||||||
tertiary = PaneAdaptedValue.Hidden,
|
|
||||||
)
|
|
||||||
else -> ThreePaneScaffoldValue(
|
|
||||||
primary = PaneAdaptedValue.Expanded,
|
primary = PaneAdaptedValue.Expanded,
|
||||||
secondary = PaneAdaptedValue.Expanded,
|
secondary = PaneAdaptedValue.Expanded,
|
||||||
tertiary = PaneAdaptedValue.Hidden,
|
tertiary = PaneAdaptedValue.Hidden,
|
||||||
|
|||||||
@@ -5,4 +5,7 @@ sealed interface MainAction {
|
|||||||
data object ClearQuery : MainAction
|
data object ClearQuery : MainAction
|
||||||
data object ClearError : MainAction
|
data object ClearError : MainAction
|
||||||
data class SaveScrollPosition(val index: Int, val offset: Int) : 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,7 +1,14 @@
|
|||||||
package com.example.research.ui.main
|
package com.example.research.ui.main
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.union
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
@@ -37,9 +44,15 @@ fun MainRoute(
|
|||||||
val results = searchViewModel.searchResults.collectAsLazyPagingItems()
|
val results = searchViewModel.searchResults.collectAsLazyPagingItems()
|
||||||
val errorMessage by searchViewModel.errorMessage.collectAsStateWithLifecycle()
|
val errorMessage by searchViewModel.errorMessage.collectAsStateWithLifecycle()
|
||||||
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
|
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
|
||||||
|
val searchHistory by searchViewModel.searchHistory.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
|
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
|
||||||
|
if (!settingsState.hasLoadedPreferences) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val isSearchBarAtBottom = settingsState.searchBarPosition == "BOTTOM"
|
||||||
|
|
||||||
val scrollPosition = remember(searchViewModel) {
|
val scrollPosition = remember(searchViewModel) {
|
||||||
searchViewModel.scrollPosition.value
|
searchViewModel.scrollPosition.value
|
||||||
@@ -47,17 +60,6 @@ fun MainRoute(
|
|||||||
val searchState = remember(searchViewModel) {
|
val searchState = remember(searchViewModel) {
|
||||||
TextFieldState(initialText = searchViewModel.searchQuery.value)
|
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) {
|
LaunchedEffect(searchState, searchQuery) {
|
||||||
if (searchState.text.toString() != searchQuery) {
|
if (searchState.text.toString() != searchQuery) {
|
||||||
@@ -79,13 +81,9 @@ fun MainRoute(
|
|||||||
|
|
||||||
val showDictionaryName = remember(settingsState) {
|
val showDictionaryName = remember(settingsState) {
|
||||||
val activeDictionaries = settingsState.dictionaries.filter { it.isActive }
|
val activeDictionaries = settingsState.dictionaries.filter { it.isActive }
|
||||||
if (activeDictionaries.isEmpty()) {
|
activeDictionaries.any { dict ->
|
||||||
false
|
val fileName = dict.path.substringAfterLast("/")
|
||||||
} else {
|
!DictionaryConfig.isDabkrsDictionary(fileName)
|
||||||
activeDictionaries.any { dict ->
|
|
||||||
val fileName = dict.path.substringAfterLast("/")
|
|
||||||
!DictionaryConfig.isDabkrsDictionary(fileName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,14 +91,37 @@ fun MainRoute(
|
|||||||
val isWide = maxWidth >= 600.dp
|
val isWide = maxWidth >= 600.dp
|
||||||
val isLandscape = maxWidth > maxHeight
|
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.onAction(SearchAction.SearchAndLoadArticle(action.query))
|
||||||
|
} else {
|
||||||
|
searchViewModel.onSearchQueryChanged(action.query)
|
||||||
|
}
|
||||||
|
is MainAction.RemoveHistoryEntry ->
|
||||||
|
searchViewModel.removeSearchHistoryEntry(action.query)
|
||||||
|
MainAction.ClearHistory -> searchViewModel.clearSearchHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isWide) {
|
if (isWide) {
|
||||||
AdaptiveMainScreen(
|
AdaptiveMainScreen(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
canSearch = canSearch,
|
canSearch = canSearch,
|
||||||
isLandscape = isLandscape,
|
isLandscape = isLandscape,
|
||||||
|
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||||
results = results,
|
results = results,
|
||||||
errorMessage = errorMessage,
|
errorMessage = errorMessage,
|
||||||
showDictionaryName = showDictionaryName,
|
showDictionaryName = showDictionaryName,
|
||||||
|
searchHistory = searchHistory,
|
||||||
articleState = articleState,
|
articleState = articleState,
|
||||||
scrollPosition = scrollPosition,
|
scrollPosition = scrollPosition,
|
||||||
onAction = onMainAction,
|
onAction = onMainAction,
|
||||||
@@ -121,9 +142,11 @@ fun MainRoute(
|
|||||||
MainScreen(
|
MainScreen(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
canSearch = canSearch,
|
canSearch = canSearch,
|
||||||
|
isSearchBarAtBottom = isSearchBarAtBottom,
|
||||||
results = results,
|
results = results,
|
||||||
errorMessage = errorMessage,
|
errorMessage = errorMessage,
|
||||||
showDictionaryName = showDictionaryName,
|
showDictionaryName = showDictionaryName,
|
||||||
|
searchHistory = searchHistory,
|
||||||
scrollPosition = scrollPosition,
|
scrollPosition = scrollPosition,
|
||||||
onAction = onMainAction,
|
onAction = onMainAction,
|
||||||
onArticleClick = onArticleClick,
|
onArticleClick = onArticleClick,
|
||||||
@@ -137,24 +160,58 @@ fun MainRoute(
|
|||||||
internal fun MainScreen(
|
internal fun MainScreen(
|
||||||
searchState: TextFieldState,
|
searchState: TextFieldState,
|
||||||
canSearch: Boolean,
|
canSearch: Boolean,
|
||||||
|
isSearchBarAtBottom: Boolean,
|
||||||
results: LazyPagingItems<IndexEntry>,
|
results: LazyPagingItems<IndexEntry>,
|
||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
showDictionaryName: Boolean,
|
showDictionaryName: Boolean,
|
||||||
|
searchHistory: List<String>,
|
||||||
scrollPosition: Pair<Int, Int>,
|
scrollPosition: Pair<Int, Int>,
|
||||||
onAction: (MainAction) -> Unit,
|
onAction: (MainAction) -> Unit,
|
||||||
onArticleClick: (IndexEntry) -> Unit,
|
onArticleClick: (IndexEntry) -> Unit,
|
||||||
onNavigateToSettings: () -> Unit
|
onNavigateToSettings: () -> Unit
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
val searchBar: @Composable () -> Unit = {
|
||||||
topBar = {
|
MainTopBar(
|
||||||
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSearchBarAtBottom) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.ime))
|
||||||
|
) {
|
||||||
|
SearchResultsPane(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
enabled = canSearch,
|
results = results,
|
||||||
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
|
errorMessage = errorMessage,
|
||||||
onClearQuery = { onAction(MainAction.ClearQuery) },
|
showDictionaryName = showDictionaryName,
|
||||||
onNavigateToSettings = onNavigateToSettings
|
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() },
|
||||||
contentWindowInsets = AppWindowInsets.navigationOnly
|
contentWindowInsets = AppWindowInsets.navigationOnly
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
SearchResultsPane(
|
SearchResultsPane(
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
package com.example.research.ui.main.components
|
package com.example.research.ui.main.components
|
||||||
|
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.only
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -17,55 +26,112 @@ import androidx.compose.material3.FilledIconButton
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.material3.ripple
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import com.example.research.ui.theme.AppWindowInsets
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
import com.example.research.common.ui.theme.dictionaryTitleLarge
|
import com.example.research.common.ui.theme.dictionaryTitleLarge
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import com.example.research.ui.theme.AppWindowInsets
|
||||||
import com.example.research.ui.theme.Spacing
|
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
|
||||||
|
|
||||||
private val TopBarHeight = Spacing.topBarHeight
|
private val TopBarHeight = Spacing.topBarHeight
|
||||||
private val TopBarShape = RoundedCornerShape(28.dp)
|
private val TopBarShape = RoundedCornerShape(28.dp)
|
||||||
|
private val HistorySheetChrome = 64.dp
|
||||||
|
private val HistorySheetMinHeight = 300.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainTopBar(
|
fun MainTopBar(
|
||||||
searchState: TextFieldState,
|
searchState: TextFieldState,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
|
isSearchBarAtBottom: Boolean = false,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
onClearQuery: () -> Unit,
|
onClearQuery: () -> Unit,
|
||||||
|
searchHistory: List<String>,
|
||||||
|
onHistoryEntryClick: (String) -> Unit,
|
||||||
|
onHistoryEntryRemove: (String) -> Unit,
|
||||||
|
onClearHistory: () -> Unit,
|
||||||
onNavigateToSettings: () -> Unit
|
onNavigateToSettings: () -> Unit
|
||||||
) {
|
) {
|
||||||
val searchIcon = painterResource(R.drawable.ic_search)
|
|
||||||
val closeIcon = painterResource(R.drawable.ic_close)
|
val closeIcon = painterResource(R.drawable.ic_close)
|
||||||
|
val historyIcon = painterResource(R.drawable.ic_manage_search)
|
||||||
val settingsIcon = painterResource(R.drawable.ic_settings)
|
val settingsIcon = painterResource(R.drawable.ic_settings)
|
||||||
val clearLabel = stringResource(R.string.clear_search)
|
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
|
||||||
|
|
||||||
|
LaunchedEffect(searchHistory.isEmpty()) {
|
||||||
|
if (searchHistory.isEmpty()) showHistorySheet = false
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onGloballyPositioned { topBarBottomPx = it.boundsInWindow().bottom.roundToInt() }
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
|
.windowInsetsPadding(
|
||||||
|
if (isSearchBarAtBottom) {
|
||||||
|
AppWindowInsets.horizontalSafeDrawing
|
||||||
|
} else {
|
||||||
|
AppWindowInsets.topAndHorizontalSafeDrawing
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (isSearchBarAtBottom) {
|
||||||
|
Modifier.windowInsetsPadding(
|
||||||
|
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp),
|
.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@@ -75,9 +141,21 @@ fun MainTopBar(
|
|||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
onQueryChange = onQueryChange,
|
onQueryChange = onQueryChange,
|
||||||
onClearQuery = onClearQuery,
|
onClearQuery = onClearQuery,
|
||||||
searchIcon = searchIcon,
|
|
||||||
closeIcon = closeIcon,
|
closeIcon = closeIcon,
|
||||||
clearLabel = clearLabel,
|
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)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,18 +178,39 @@ fun MainTopBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showHistorySheet) {
|
||||||
|
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
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
private fun SearchField(
|
private fun SearchField(
|
||||||
searchState: TextFieldState,
|
searchState: TextFieldState,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
onClearQuery: () -> Unit,
|
onClearQuery: () -> Unit,
|
||||||
searchIcon: androidx.compose.ui.graphics.painter.Painter,
|
closeIcon: Painter,
|
||||||
closeIcon: androidx.compose.ui.graphics.painter.Painter,
|
|
||||||
clearLabel: String,
|
clearLabel: String,
|
||||||
|
historyIcon: Painter,
|
||||||
|
historyLabel: String,
|
||||||
|
historyEnabled: Boolean,
|
||||||
|
onShowHistory: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
@@ -134,11 +233,21 @@ private fun SearchField(
|
|||||||
lineLimits = TextFieldLineLimits.SingleLine,
|
lineLimits = TextFieldLineLimits.SingleLine,
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
painter = searchIcon,
|
painter = historyIcon,
|
||||||
contentDescription = null,
|
contentDescription = historyLabel,
|
||||||
|
tint = LocalContentColor.current.copy(
|
||||||
|
alpha = if (historyEnabled) 1f else 0.38f
|
||||||
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = 4.dp)
|
.offset(x = 4.dp)
|
||||||
.size(24.dp)
|
.size(28.dp)
|
||||||
|
.testTag("history_button")
|
||||||
|
.semantics { testTagsAsResourceId = true }
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = ripple(bounded = false, radius = 20.dp),
|
||||||
|
enabled = historyEnabled
|
||||||
|
) { onShowHistory() }
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingIcon = if (searchState.text.isNotEmpty()) {
|
trailingIcon = if (searchState.text.isNotEmpty()) {
|
||||||
@@ -183,3 +292,48 @@ private fun SearchField(
|
|||||||
.semantics { testTagsAsResourceId = true }
|
.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
|
||||||
|
var frozenHeight by remember { mutableStateOf<Dp?>(null) }
|
||||||
|
if (frozenHeight == null) {
|
||||||
|
frozenHeight = if (isSearchBarAtBottom) {
|
||||||
|
screenHeight - HistorySheetChrome - with(density) {
|
||||||
|
WindowInsets.navigationBars.getBottom(this).toDp()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
screenHeight - with(density) { topBarBottomPx.toDp() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val maxContentHeight = frozenHeight!!
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.example.research.ui.main.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.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.material3.ripple
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
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.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
|
||||||
|
.testTag("search_history")
|
||||||
|
.semantics { testTagsAsResourceId = true },
|
||||||
|
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)
|
||||||
|
.testTag("clear_history")
|
||||||
|
.semantics { testTagsAsResourceId = true }
|
||||||
|
.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()
|
||||||
|
.testTag("search_history_entry")
|
||||||
|
.semantics { testTagsAsResourceId = true }
|
||||||
|
.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
|
||||||
|
.testTag("remove_history_entry")
|
||||||
|
.semantics { testTagsAsResourceId = true }
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = ripple(bounded = false, radius = 20.dp)
|
||||||
|
) { onEntryRemove(entry) }
|
||||||
|
.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.example.research.ui.main.components
|
package com.example.research.ui.main.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.*
|
import androidx.compose.foundation.lazy.*
|
||||||
@@ -56,20 +55,12 @@ fun SearchResultsList(
|
|||||||
val entry = if (showStale) staleEntries.getOrNull(index)
|
val entry = if (showStale) staleEntries.getOrNull(index)
|
||||||
else if (index in 0 until results.itemCount) results.peek(index) else null
|
else if (index in 0 until results.itemCount) results.peek(index) else null
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
Box(
|
SearchResultItem(
|
||||||
modifier = Modifier.animateItem(
|
entry = entry,
|
||||||
fadeInSpec = if (index < 8) tween(150) else null,
|
resultIndex = index,
|
||||||
placementSpec = null,
|
onArticleClick = onArticleClick,
|
||||||
fadeOutSpec = null
|
showDictionaryName = showDictionaryName
|
||||||
)
|
)
|
||||||
) {
|
|
||||||
SearchResultItem(
|
|
||||||
entry = entry,
|
|
||||||
resultIndex = index,
|
|
||||||
onArticleClick = onArticleClick,
|
|
||||||
showDictionaryName = showDictionaryName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.*
|
import androidx.compose.runtime.saveable.*
|
||||||
@@ -39,7 +38,6 @@ enum class Screen {
|
|||||||
Home, Settings, Article, About
|
Home, Settings, Article, About
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavigation(
|
fun AppNavigation(
|
||||||
searchViewModel: SearchViewModel,
|
searchViewModel: SearchViewModel,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@@ -46,17 +45,18 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import com.example.research.R
|
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.appTestTag
|
||||||
import com.example.research.common.ui.components.CollapsibleHeader
|
import com.example.research.common.ui.components.CollapsibleHeader
|
||||||
import com.example.research.common.ui.components.SectionCard
|
import com.example.research.common.ui.components.SectionCard
|
||||||
import com.example.research.ui.settings.components.DictionaryManagement
|
import com.example.research.ui.settings.components.DictionaryManagement
|
||||||
import com.example.research.ui.settings.components.LanguageSelectionSection
|
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 com.example.research.ui.settings.components.ThemeSelectionSection
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsRoute(
|
fun SettingsRoute(
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
@@ -126,12 +126,10 @@ fun SettingsScreen(
|
|||||||
title = { Text(stringResource(R.string.settings_title)) },
|
title = { Text(stringResource(R.string.settings_title)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (showBackButton) {
|
if (showBackButton) {
|
||||||
IconButton(onClick = onNavigateBack) {
|
AppBackButton(
|
||||||
Icon(
|
onClick = onNavigateBack,
|
||||||
painter = painterResource(R.drawable.ic_arrow_back),
|
modifier = Modifier.padding(start = 6.dp)
|
||||||
contentDescription = stringResource(R.string.action_back)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
@@ -240,6 +238,24 @@ 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") {
|
item(key = "bottom-spacer", contentType = "spacer") {
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import com.example.research.feature.download.model.DownloadState
|
|||||||
data class SettingsUiState(
|
data class SettingsUiState(
|
||||||
val theme: AppTheme = AppTheme.SYSTEM,
|
val theme: AppTheme = AppTheme.SYSTEM,
|
||||||
val language: String = "system",
|
val language: String = "system",
|
||||||
|
val searchBarPosition: String = "TOP",
|
||||||
val isThemeExpanded: Boolean = false,
|
val isThemeExpanded: Boolean = false,
|
||||||
val isLanguageExpanded: Boolean = false,
|
val isLanguageExpanded: Boolean = false,
|
||||||
|
val isSearchBarExpanded: Boolean = false,
|
||||||
val isDictionariesExpanded: Boolean = true,
|
val isDictionariesExpanded: Boolean = true,
|
||||||
val dictionaries: List<Dictionary> = emptyList(),
|
val dictionaries: List<Dictionary> = emptyList(),
|
||||||
val dictionaryStatus: DictionaryStatus = DictionaryStatus.Unknown,
|
val dictionaryStatus: DictionaryStatus = DictionaryStatus.Unknown,
|
||||||
@@ -23,6 +25,7 @@ data class SettingsUiState(
|
|||||||
val importState: ImportState = ImportState.Idle,
|
val importState: ImportState = ImportState.Idle,
|
||||||
val dictionarySources: List<DictionarySource> = emptyList(),
|
val dictionarySources: List<DictionarySource> = emptyList(),
|
||||||
val hasCompletedStartupScan: Boolean = false,
|
val hasCompletedStartupScan: Boolean = false,
|
||||||
|
val hasLoadedPreferences: Boolean = false,
|
||||||
val pipelineIdle: Boolean = true,
|
val pipelineIdle: Boolean = true,
|
||||||
val appVersion: String = "1.0",
|
val appVersion: String = "1.0",
|
||||||
)
|
)
|
||||||
@@ -30,6 +33,7 @@ data class SettingsUiState(
|
|||||||
sealed interface SettingsUiEvent {
|
sealed interface SettingsUiEvent {
|
||||||
data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent
|
data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent
|
||||||
data class LanguageChanged(val language: String) : SettingsUiEvent
|
data class LanguageChanged(val language: String) : SettingsUiEvent
|
||||||
|
data class SearchBarPositionChanged(val position: String) : SettingsUiEvent
|
||||||
data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent
|
data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent
|
||||||
data class DictionaryToggled(val path: String) : SettingsUiEvent
|
data class DictionaryToggled(val path: String) : SettingsUiEvent
|
||||||
data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent
|
data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent
|
||||||
|
|||||||
@@ -46,12 +46,15 @@ private data class DictionaryStateInputs(
|
|||||||
private data class OperationStateInputs(
|
private data class OperationStateInputs(
|
||||||
val downloadState: DownloadState,
|
val downloadState: DownloadState,
|
||||||
val language: String,
|
val language: String,
|
||||||
|
val searchBarPosition: String,
|
||||||
val importState: ImportState,
|
val importState: ImportState,
|
||||||
val dictionaryStatus: DictionaryStatus,
|
val dictionaryStatus: DictionaryStatus,
|
||||||
val hasCompletedStartupScan: Boolean,
|
val hasCompletedStartupScan: Boolean,
|
||||||
val isThemeExpanded: Boolean,
|
val isThemeExpanded: Boolean,
|
||||||
val isLanguageExpanded: Boolean,
|
val isLanguageExpanded: Boolean,
|
||||||
|
val isSearchBarExpanded: Boolean,
|
||||||
val isDictionariesExpanded: Boolean,
|
val isDictionariesExpanded: Boolean,
|
||||||
|
val hasLoadedPreferences: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
@@ -89,7 +92,9 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
private val dictionaryStatus = MutableStateFlow<DictionaryStatus>(DictionaryStatus.Unknown)
|
private val dictionaryStatus = MutableStateFlow<DictionaryStatus>(DictionaryStatus.Unknown)
|
||||||
private val language = MutableStateFlow("system")
|
private val language = MutableStateFlow("system")
|
||||||
|
private val searchBarPosition = MutableStateFlow("TOP")
|
||||||
private val hasCompletedStartupScan = MutableStateFlow(false)
|
private val hasCompletedStartupScan = MutableStateFlow(false)
|
||||||
|
private val hasLoadedPreferences = MutableStateFlow(false)
|
||||||
|
|
||||||
private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>()
|
private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>()
|
||||||
private var isDownloadInProgress = false
|
private var isDownloadInProgress = false
|
||||||
@@ -133,8 +138,6 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
private fun setupStateObservation() {
|
private fun setupStateObservation() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
var wasIndexing = false
|
|
||||||
|
|
||||||
val dictionaryStateInputs = combine(
|
val dictionaryStateInputs = combine(
|
||||||
preferencesManager.themeMode.map { mode ->
|
preferencesManager.themeMode.map { mode ->
|
||||||
try { AppTheme.valueOf(mode) } catch (_: Exception) { AppTheme.SYSTEM }
|
try { AppTheme.valueOf(mode) } catch (_: Exception) { AppTheme.SYSTEM }
|
||||||
@@ -153,39 +156,34 @@ class SettingsViewModel(
|
|||||||
val operationStateInputs = combine(
|
val operationStateInputs = combine(
|
||||||
downloadManager.downloadState,
|
downloadManager.downloadState,
|
||||||
language,
|
language,
|
||||||
|
searchBarPosition,
|
||||||
dictionaryImportManager.importState,
|
dictionaryImportManager.importState,
|
||||||
dictionaryStatus,
|
dictionaryStatus,
|
||||||
hasCompletedStartupScan,
|
hasCompletedStartupScan,
|
||||||
preferencesManager.isThemeExpanded,
|
preferencesManager.isThemeExpanded,
|
||||||
preferencesManager.isLanguageExpanded,
|
preferencesManager.isLanguageExpanded,
|
||||||
|
preferencesManager.isSearchBarExpanded,
|
||||||
preferencesManager.isDictionariesExpanded,
|
preferencesManager.isDictionariesExpanded,
|
||||||
|
hasLoadedPreferences,
|
||||||
) { params: Array<Any> ->
|
) { params: Array<Any> ->
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
OperationStateInputs(
|
OperationStateInputs(
|
||||||
downloadState = params[0] as DownloadState,
|
downloadState = params[0] as DownloadState,
|
||||||
language = params[1] as String,
|
language = params[1] as String,
|
||||||
importState = params[2] as ImportState,
|
searchBarPosition = params[2] as String,
|
||||||
dictionaryStatus = params[3] as DictionaryStatus,
|
importState = params[3] as ImportState,
|
||||||
hasCompletedStartupScan = params[4] as Boolean,
|
dictionaryStatus = params[4] as DictionaryStatus,
|
||||||
isThemeExpanded = params[5] as Boolean,
|
hasCompletedStartupScan = params[5] as Boolean,
|
||||||
isLanguageExpanded = params[6] as Boolean,
|
isThemeExpanded = params[6] as Boolean,
|
||||||
isDictionariesExpanded = params[7] as Boolean,
|
isLanguageExpanded = params[7] as Boolean,
|
||||||
|
isSearchBarExpanded = params[8] as Boolean,
|
||||||
|
isDictionariesExpanded = params[9] as Boolean,
|
||||||
|
hasLoadedPreferences = params[10] as Boolean,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
combine(dictionaryStateInputs, operationStateInputs) { dictionaryState, operationState ->
|
combine(dictionaryStateInputs, operationStateInputs) { dictionaryState, operationState ->
|
||||||
// Handle indexing completion side effect
|
|
||||||
val isIndexing = dictionaryState.indexingProgress.isIndexing
|
val isIndexing = dictionaryState.indexingProgress.isIndexing
|
||||||
if (wasIndexing && !isIndexing) {
|
|
||||||
if (isDownloadInProgress) {
|
|
||||||
isDownloadInProgress = false
|
|
||||||
dictionaryStatus.value = withContext(Dispatchers.IO) {
|
|
||||||
evaluateDictionaryStatus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wasIndexing = isIndexing
|
|
||||||
|
|
||||||
when (operationState.downloadState) {
|
when (operationState.downloadState) {
|
||||||
is DownloadState.Loading, is DownloadState.Extracting -> {
|
is DownloadState.Loading, is DownloadState.Extracting -> {
|
||||||
isDownloadInProgress = true
|
isDownloadInProgress = true
|
||||||
@@ -194,6 +192,10 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
is DownloadState.Success -> {
|
is DownloadState.Success -> {
|
||||||
pendingSourceUrls.clear()
|
pendingSourceUrls.clear()
|
||||||
|
isDownloadInProgress = false
|
||||||
|
dictionaryStatus.value = withContext(Dispatchers.IO) {
|
||||||
|
evaluateDictionaryStatus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is DownloadState.Error -> {
|
is DownloadState.Error -> {
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
@@ -251,14 +253,17 @@ class SettingsViewModel(
|
|||||||
indexingProgress = IndexingProgress(isIndexing = isIndexing),
|
indexingProgress = IndexingProgress(isIndexing = isIndexing),
|
||||||
downloadState = operationState.downloadState,
|
downloadState = operationState.downloadState,
|
||||||
language = operationState.language,
|
language = operationState.language,
|
||||||
|
searchBarPosition = operationState.searchBarPosition,
|
||||||
importState = operationState.importState,
|
importState = operationState.importState,
|
||||||
dictionaryStatus = operationState.dictionaryStatus,
|
dictionaryStatus = operationState.dictionaryStatus,
|
||||||
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
|
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
|
||||||
|
hasLoadedPreferences = operationState.hasLoadedPreferences,
|
||||||
pipelineIdle = !isIndexing &&
|
pipelineIdle = !isIndexing &&
|
||||||
!operationState.downloadState.isActive &&
|
!operationState.downloadState.isActive &&
|
||||||
!operationState.importState.isActive,
|
!operationState.importState.isActive,
|
||||||
isThemeExpanded = operationState.isThemeExpanded,
|
isThemeExpanded = operationState.isThemeExpanded,
|
||||||
isLanguageExpanded = operationState.isLanguageExpanded,
|
isLanguageExpanded = operationState.isLanguageExpanded,
|
||||||
|
isSearchBarExpanded = operationState.isSearchBarExpanded,
|
||||||
isDictionariesExpanded = operationState.isDictionariesExpanded,
|
isDictionariesExpanded = operationState.isDictionariesExpanded,
|
||||||
appVersion = _uiState.value.appVersion,
|
appVersion = _uiState.value.appVersion,
|
||||||
)
|
)
|
||||||
@@ -269,9 +274,15 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun initialize() {
|
private fun initialize() {
|
||||||
|
var scanCompleted = false
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
scanDirectoryInternal(preferencesManager.dictionaryPath)
|
scanCompleted = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
|
||||||
|
scanDirectoryInternal(preferencesManager.dictionaryPath) is OperationResult.Success
|
||||||
|
} else {
|
||||||
|
dictionaryStatus.value = DictionaryStatus.UpToDate
|
||||||
|
false
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("SettingsViewModel", "Initial scan failed", e)
|
android.util.Log.e("SettingsViewModel", "Initial scan failed", e)
|
||||||
dictionaryStatus.value = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
|
dictionaryStatus.value = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
|
||||||
@@ -287,10 +298,18 @@ class SettingsViewModel(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
|
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
searchBarPosition.value = preferencesManager.searchBarPosition.first()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("SettingsViewModel", "Failed to load search bar position: ${e.message}")
|
||||||
|
}
|
||||||
|
hasLoadedPreferences.value = true
|
||||||
hasCompletedStartupScan.value = true
|
hasCompletedStartupScan.value = true
|
||||||
preferencesManager.setHadNoDictionaries(
|
if (scanCompleted) {
|
||||||
localDictionaryRepository.dictionaries.value.isEmpty()
|
preferencesManager.setHadNoDictionaries(
|
||||||
)
|
localDictionaryRepository.dictionaries.value.isEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,6 +318,7 @@ class SettingsViewModel(
|
|||||||
when (event) {
|
when (event) {
|
||||||
is SettingsUiEvent.ThemeChanged -> setTheme(event.theme)
|
is SettingsUiEvent.ThemeChanged -> setTheme(event.theme)
|
||||||
is SettingsUiEvent.LanguageChanged -> saveLanguage(event.language)
|
is SettingsUiEvent.LanguageChanged -> saveLanguage(event.language)
|
||||||
|
is SettingsUiEvent.SearchBarPositionChanged -> saveSearchBarPosition(event.position)
|
||||||
is SettingsUiEvent.SectionToggled -> saveSectionExpanded(event.key, event.isExpanded)
|
is SettingsUiEvent.SectionToggled -> saveSectionExpanded(event.key, event.isExpanded)
|
||||||
is SettingsUiEvent.DictionaryToggled -> toggleDictionaryActive(event.path)
|
is SettingsUiEvent.DictionaryToggled -> toggleDictionaryActive(event.path)
|
||||||
is SettingsUiEvent.DictionaryDeleted -> deleteDictionary(event.dictionary)
|
is SettingsUiEvent.DictionaryDeleted -> deleteDictionary(event.dictionary)
|
||||||
@@ -371,6 +391,13 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveSearchBarPosition(position: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesManager.saveSearchBarPosition(position)
|
||||||
|
searchBarPosition.value = position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveLanguage(languageCode: String) {
|
private fun saveLanguage(languageCode: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferencesManager.saveLanguage(languageCode)
|
preferencesManager.saveLanguage(languageCode)
|
||||||
@@ -528,6 +555,25 @@ class SettingsViewModel(
|
|||||||
): List<DictionarySource> =
|
): List<DictionarySource> =
|
||||||
DictionarySourceFileMatcher.installedSources(sources, dictionaries)
|
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() {
|
private fun cancelDownload() {
|
||||||
// Cancel the ViewModel-side scan job so no further status recomputes
|
// Cancel the ViewModel-side scan job so no further status recomputes
|
||||||
localDictionaryRepository.cancelIndexing()
|
localDictionaryRepository.cancelIndexing()
|
||||||
@@ -588,7 +634,10 @@ class SettingsViewModel(
|
|||||||
"NeedsUpdate" -> DictionaryStatus.NeedsUpdate
|
"NeedsUpdate" -> DictionaryStatus.NeedsUpdate
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (restoredStatus != null && savedStatusDate == DateUtils.getCurrentDateString()) {
|
if (restoredStatus != null &&
|
||||||
|
savedStatusDate == DateUtils.getCurrentDateString() &&
|
||||||
|
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }
|
||||||
|
) {
|
||||||
dictionaryStatus.value = restoredStatus
|
dictionaryStatus.value = restoredStatus
|
||||||
} else {
|
} else {
|
||||||
dictionaryStatus.value = DictionaryStatus.Checking
|
dictionaryStatus.value = DictionaryStatus.Checking
|
||||||
|
|||||||
+4
-1
@@ -10,6 +10,8 @@ import androidx.compose.animation.shrinkVertically
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -218,7 +220,8 @@ fun DictionaryManagement(
|
|||||||
focusManager.clearFocus(force = true)
|
focusManager.clearFocus(force = true)
|
||||||
setShowUrlBottomSheet(false)
|
setShowUrlBottomSheet(false)
|
||||||
},
|
},
|
||||||
sheetState = sheetState
|
sheetState = sheetState,
|
||||||
|
contentWindowInsets = { WindowInsets.statusBars }
|
||||||
) {
|
) {
|
||||||
DictionaryUrlInputContent(
|
DictionaryUrlInputContent(
|
||||||
existingSources = sources,
|
existingSources = sources,
|
||||||
|
|||||||
+33
-29
@@ -3,6 +3,9 @@ package com.example.research.ui.settings.components
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
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.foundation.verticalScroll
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -16,7 +19,6 @@ import androidx.compose.ui.semantics.semantics
|
|||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
import com.example.research.core.domain.model.DictionarySource
|
import com.example.research.core.domain.model.DictionarySource
|
||||||
@@ -64,6 +66,20 @@ private fun validateDictionaryUrl(context: UrlValidationContext): String? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@Composable
|
||||||
fun DictionaryUrlInputContent(
|
fun DictionaryUrlInputContent(
|
||||||
existingSources: List<DictionarySource>,
|
existingSources: List<DictionarySource>,
|
||||||
@@ -71,7 +87,7 @@ fun DictionaryUrlInputContent(
|
|||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val urls = remember { mutableStateListOf(TextFieldValue("")) }
|
val urls = remember { mutableStateListOf(TextFieldState("")) }
|
||||||
val validationErrors = remember { mutableStateMapOf<Int, String>() }
|
val validationErrors = remember { mutableStateMapOf<Int, String>() }
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
@@ -96,8 +112,8 @@ fun DictionaryUrlInputContent(
|
|||||||
// Cache prefixes for all input URLs to avoid repeated extraction during recomposition
|
// Cache prefixes for all input URLs to avoid repeated extraction during recomposition
|
||||||
val inputUrlPrefixes = remember {
|
val inputUrlPrefixes = remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
urls.associate { urlValue ->
|
urls.associate { urlState ->
|
||||||
val urlString = urlValue.text.trim()
|
val urlString = urlState.text.trim().toString()
|
||||||
urlString to if (urlString.isNotEmpty()) {
|
urlString to if (urlString.isNotEmpty()) {
|
||||||
DictionarySource.extractPrefix(urlString)
|
DictionarySource.extractPrefix(urlString)
|
||||||
} else null
|
} else null
|
||||||
@@ -119,10 +135,10 @@ fun DictionaryUrlInputContent(
|
|||||||
style = MaterialTheme.typography.headlineSmall
|
style = MaterialTheme.typography.headlineSmall
|
||||||
)
|
)
|
||||||
|
|
||||||
urls.forEachIndexed { index, urlValue ->
|
urls.forEachIndexed { index, urlState ->
|
||||||
val isLastItem = index == urls.lastIndex
|
val isLastItem = index == urls.lastIndex
|
||||||
|
|
||||||
val urlString = urlValue.text.trim()
|
val urlString = urlState.text.trim().toString()
|
||||||
val cachedPrefix = inputUrlPrefixes.value[urlString]
|
val cachedPrefix = inputUrlPrefixes.value[urlString]
|
||||||
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
|
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
|
||||||
validateDictionaryUrl(
|
validateDictionaryUrl(
|
||||||
@@ -130,7 +146,7 @@ fun DictionaryUrlInputContent(
|
|||||||
url = urlString,
|
url = urlString,
|
||||||
existingSources = existingSources,
|
existingSources = existingSources,
|
||||||
existingPrefixes = existingPrefixes,
|
existingPrefixes = existingPrefixes,
|
||||||
urlsInForm = urls.map { it.text.trim() },
|
urlsInForm = urls.map { it.text.trim().toString() },
|
||||||
urlPrefix = cachedPrefix,
|
urlPrefix = cachedPrefix,
|
||||||
errorMessages = errorMessages
|
errorMessages = errorMessages
|
||||||
)
|
)
|
||||||
@@ -140,20 +156,8 @@ fun DictionaryUrlInputContent(
|
|||||||
val displayError = validationErrors[index] ?: realtimeError
|
val displayError = validationErrors[index] ?: realtimeError
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = urlValue,
|
state = urlState,
|
||||||
onValueChange = { newValue ->
|
inputTransformation = urlInputTransformation,
|
||||||
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}") },
|
label = { Text(if (index == 0) stringResource(R.string.dictionary_source_url_hint) else "URL ${index + 1}") },
|
||||||
isError = displayError != null,
|
isError = displayError != null,
|
||||||
supportingText = displayError?.let { { Text(it) } },
|
supportingText = displayError?.let { { Text(it) } },
|
||||||
@@ -168,7 +172,7 @@ fun DictionaryUrlInputContent(
|
|||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
singleLine = true,
|
lineLimits = TextFieldLineLimits.SingleLine,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Uri,
|
keyboardType = KeyboardType.Uri,
|
||||||
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next
|
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next
|
||||||
@@ -188,7 +192,7 @@ fun DictionaryUrlInputContent(
|
|||||||
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
urls.add(TextFieldValue(""))
|
urls.add(TextFieldState(""))
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.Start)
|
.align(Alignment.Start)
|
||||||
@@ -224,8 +228,8 @@ fun DictionaryUrlInputContent(
|
|||||||
val validUrls = mutableListOf<String>()
|
val validUrls = mutableListOf<String>()
|
||||||
val seenUrls = mutableSetOf<String>()
|
val seenUrls = mutableSetOf<String>()
|
||||||
|
|
||||||
urls.forEachIndexed { index, urlValue ->
|
urls.forEachIndexed { index, urlState ->
|
||||||
val urlString = urlValue.text.trim()
|
val urlString = urlState.text.trim().toString()
|
||||||
if (urlString.isBlank()) {
|
if (urlString.isBlank()) {
|
||||||
if (urls.size == 1) {
|
if (urls.size == 1) {
|
||||||
validationErrors[index] = errorMessages.empty
|
validationErrors[index] = errorMessages.empty
|
||||||
@@ -263,9 +267,9 @@ fun DictionaryUrlInputContent(
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.testTag("add_confirm_button")
|
.testTag("add_confirm_button")
|
||||||
.semantics { testTagsAsResourceId = true },
|
.semantics { testTagsAsResourceId = true },
|
||||||
enabled = urls.any { it.text.trim().isNotBlank() } &&
|
enabled = urls.any { it.text.trim().toString().isNotBlank() } &&
|
||||||
urls.none { urlValue ->
|
urls.none { urlState ->
|
||||||
val urlString = urlValue.text.trim()
|
val urlString = urlState.text.trim().toString()
|
||||||
if (urlString.isEmpty()) {
|
if (urlString.isEmpty()) {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
@@ -275,7 +279,7 @@ fun DictionaryUrlInputContent(
|
|||||||
url = urlString,
|
url = urlString,
|
||||||
existingSources = existingSources,
|
existingSources = existingSources,
|
||||||
existingPrefixes = existingPrefixes,
|
existingPrefixes = existingPrefixes,
|
||||||
urlsInForm = urls.map { it.text.trim() },
|
urlsInForm = urls.map { it.text.trim().toString() },
|
||||||
urlPrefix = urlPrefix,
|
urlPrefix = urlPrefix,
|
||||||
errorMessages = errorMessages
|
errorMessages = errorMessages
|
||||||
)
|
)
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchBarPositionSection(
|
||||||
|
currentPosition: String,
|
||||||
|
onPositionChanged: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val positions = listOf(
|
||||||
|
"TOP" to stringResource(R.string.search_bar_position_top),
|
||||||
|
"BOTTOM" to stringResource(R.string.search_bar_position_bottom)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
positions.forEach { (code, label) ->
|
||||||
|
val isSelected = currentPosition == code
|
||||||
|
OutlinedChoiceButton(
|
||||||
|
text = label,
|
||||||
|
onClick = { onPositionChanged(code) },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.testTag("search_bar_$code"),
|
||||||
|
selected = isSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,6 @@ object Spacing {
|
|||||||
// Semantic spacing constants for specific UI elements
|
// Semantic spacing constants for specific UI elements
|
||||||
val topBarPadding = 8.dp
|
val topBarPadding = 8.dp
|
||||||
val topBarHeight = 56.dp
|
val topBarHeight = 56.dp
|
||||||
val iconStart = 32.dp
|
|
||||||
val searchTextStart = 68.dp
|
|
||||||
val listItemVertical = 8.dp
|
val listItemVertical = 8.dp
|
||||||
|
|
||||||
val contentStart = 32.dp
|
val contentStart = 32.dp
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
||||||
@@ -12,7 +12,13 @@
|
|||||||
<string name="language_english">Английский</string>
|
<string name="language_english">Английский</string>
|
||||||
<string name="language_russian">Русский</string>
|
<string name="language_russian">Русский</string>
|
||||||
<string name="language_system">Системный</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="clear_search">Очистить поиск</string>
|
||||||
|
<string name="recent_searches">История поиска</string>
|
||||||
|
<string name="clear_search_history">Очистить</string>
|
||||||
|
<string name="remove_search_history_entry">Удалить из истории</string>
|
||||||
<string name="article_return_to_search">Вернуться к поиску</string>
|
<string name="article_return_to_search">Вернуться к поиску</string>
|
||||||
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
|
<string name="error_checking_dictionaries">Не удалось проверить статус словарей</string>
|
||||||
<string name="error_loading_article">Ошибка загрузки: %1$s</string>
|
<string name="error_loading_article">Ошибка загрузки: %1$s</string>
|
||||||
@@ -76,7 +82,7 @@
|
|||||||
<string name="dictionary_source_duplicate_in_form">Дублирующийся URL в форме</string>
|
<string name="dictionary_source_duplicate_in_form">Дублирующийся URL в форме</string>
|
||||||
<string name="dictionary_source_duplicate_prefix">Этот словарь уже существует (одинаковый префикс)</string>
|
<string name="dictionary_source_duplicate_prefix">Этот словарь уже существует (одинаковый префикс)</string>
|
||||||
<string name="dictionary_source_url_empty">URL не может быть пустым</string>
|
<string name="dictionary_source_url_empty">URL не может быть пустым</string>
|
||||||
<string name="dictionary_source_invalid_protocol">Должен начинаться с http:// или https://</string>
|
<string name="dictionary_source_invalid_protocol">Должен начинаться с https://</string>
|
||||||
<string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dsl.dz</string>
|
<string name="dictionary_source_invalid_extension">Должен заканчиваться на .gz, .dsl или .dsl.dz</string>
|
||||||
<string name="dictionary_source_too_long">URL слишком длинный (максимум 2048 символов)</string>
|
<string name="dictionary_source_too_long">URL слишком длинный (максимум 2048 символов)</string>
|
||||||
<string name="dictionary_source_invalid_url">Неверный формат URL</string>
|
<string name="dictionary_source_invalid_url">Неверный формат URL</string>
|
||||||
|
|||||||
@@ -12,7 +12,13 @@
|
|||||||
<string name="language_english">English</string>
|
<string name="language_english">English</string>
|
||||||
<string name="language_russian">Russian</string>
|
<string name="language_russian">Russian</string>
|
||||||
<string name="language_system">System</string>
|
<string name="language_system">System</string>
|
||||||
|
<string name="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="clear_search">Clear search</string>
|
||||||
|
<string name="recent_searches">Search history</string>
|
||||||
|
<string name="clear_search_history">Clear</string>
|
||||||
|
<string name="remove_search_history_entry">Remove from history</string>
|
||||||
<string name="article_return_to_search">Return to search</string>
|
<string name="article_return_to_search">Return to search</string>
|
||||||
<string name="error_checking_dictionaries">Failed to check dictionary status</string>
|
<string name="error_checking_dictionaries">Failed to check dictionary status</string>
|
||||||
<string name="error_loading_article">Loading error: %1$s</string>
|
<string name="error_loading_article">Loading error: %1$s</string>
|
||||||
@@ -74,7 +80,7 @@
|
|||||||
<string name="dictionary_source_duplicate_in_form">Duplicate URL in the form</string>
|
<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_duplicate_prefix">This dictionary already exists</string>
|
||||||
<string name="dictionary_source_url_empty">URL cannot be empty</string>
|
<string name="dictionary_source_url_empty">URL cannot be empty</string>
|
||||||
<string name="dictionary_source_invalid_protocol">Must start with http:// or https://</string>
|
<string name="dictionary_source_invalid_protocol">Must start with https://</string>
|
||||||
<string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dsl.dz</string>
|
<string name="dictionary_source_invalid_extension">Must end with .gz, .dsl, or .dsl.dz</string>
|
||||||
<string name="dictionary_source_too_long">URL is too long (max 2048 characters)</string>
|
<string name="dictionary_source_too_long">URL is too long (max 2048 characters)</string>
|
||||||
<string name="dictionary_source_invalid_url">Invalid URL format</string>
|
<string name="dictionary_source_invalid_url">Invalid URL format</string>
|
||||||
|
|||||||
Reference in new issue
Block a user