23 Commits
Author SHA1 Message Date
OneWay cea46c0955 fix(parser): keep the full headword in the display text
The display form was truncated at the first formatting tag, so headwords
with a mid-word stress mark displayed as their prefix only. Build the
display from the full text with tags stripped and render stress marks as
a combining acute accent. Bump the index version so existing indexes
rebuild.
2026-10-06 12:23:50 +08:00
OneWay 37127b7bc9 fix(about): correct the version display
Drop the v prefix so the version shows as a plain version name, and
align it by baseline with the app name instead of the bottom edge of
the headline line box.
2026-10-06 11:10:19 +08:00
OneWay 84fcd77f02 feat(settings): split pasted dictionary URLs into separate fields
Pasting several URLs separated by spaces, newlines or commas into one
field now distributes them across separate fields when every token is a
valid source template. Previously the joined text passed validation as a
single URL and failed at download time.
2026-10-06 11:08:37 +08:00
OneWay 3d83d04cea style: address IDE warnings
Remove unused imports and an unused spacing constant, drop redundant if
checks and redundant opt-in markers, replace loop variables with
destructuring, convert a string concatenation to a template, use
Configuration.screenHeightDp instead of LocalWindowInfo.containerSize, and
opt in to ExperimentalSerializationApi for decodeFromStream.
2026-10-06 09:42:54 +08:00
OneWay 4b627b4d05 fix(search): rebuild the results pager only when the dictionary set changes
Background metadata loading rewrote the dictionaries flow several times
after startup, and every emission recreated the Pager: the results list
visibly collapsed and refilled. Distinct on the index paths that paging
actually uses, and seed the state synchronously instead of an empty list.
2026-10-06 09:42:54 +08:00
OneWay 5c655e769c fix(settings): skip the startup scan when dictionaries are already loaded
A second activity instance (PROCESS_TEXT) ran the full directory scan
again, and the main screen stayed blank until the scan finished. The
repository is a process-wide singleton: when it already holds
dictionaries, mark them up to date and load preferences immediately.
2026-10-06 09:42:54 +08:00
OneWay 0c4ab6dad5 fix(search): keep PROCESS_TEXT on the results screen
handleExternalSearch auto-opened the article on an exact match, so the main
screen with the search bar flashed before the article covered it. Only set
the query; the article now opens from the results list.
2026-10-06 09:42:54 +08:00
OneWay 87dccb4993 fix(article): stabilize baseTextStyle across recompositions
TextStyle.copy() returned a new instance on every recomposition, so the
remember() cache for the rendered blocks never hit. A text selection re-ran
block merging and recreated all article text nodes, rebuilding their
StaticLayouts repeatedly. Hoist the theme values and remember the style.
2026-10-06 09:42:54 +08:00
OneWay 9de47c7f0f perf(about): parse the license catalog with decodeFromStream
Stream the bundled license catalog straight into the JSON decoder
instead of buffering the whole resource as a string first, halving
parse time and peak allocation when the About screen loads.
2026-10-05 05:18:57 +08:00
OneWay 9b6a34b607 fix(settings): write the no-dictionaries seed only after a completed scan
A racing SettingsViewModel whose scan short-circuits with an in-progress
error persisted hadNoDictionaries from a still-empty list, forcing the
Settings screen on the next cold start despite existing dictionaries.
2026-10-05 05:16:21 +08:00
OneWay d2c9dd7701 fix(settings): pin Add Source container to static insets
With many fields, the Add Source sheet could keep re-triggering its
settle animation: animated window insets reached the container while
its height followed the content. Keep only the static status bar
inset on the sheet container; the content already pads itself for
the navigation bar and IME.
2026-10-04 12:36:00 +00:00
OneWay f227546111 fix(search): remove result row fade-in when the query changes
Remove the fade-in animation from result rows so that items render
directly when the query changes.
2026-10-04 12:33:00 +00:00
OneWay cec914ed00 feat(search): search history bottom sheet
Record queries on explicit search and keep up to 100 entries.
Show them in a bottom sheet opened from the search bar, with
per-entry removal and clear-all.
2026-10-04 12:30:00 +00:00
OneWay 73384bc0eb feat(settings): place the search bar at the top or the bottom
Add a search bar position setting and render the bar above the
results or below them, with window insets applied to match.
2026-10-04 12:24:00 +00:00
OneWay c4e4664542 feat(article): adaptive compact top bar
Top-align the title row in compact layouts, align the back button
with the first title line using the shared back button, make the
title start padding depend on whether the back button is shown,
and drop the spacing constant the old arrow buttons used.
2026-10-04 12:21:00 +00:00
OneWay 59dfb53756 fix(ui): align back arrows with the search bar leading icon
Use one shared back button with a matching icon size, touch target
and start padding on the settings and about screens.
2026-10-04 12:18:00 +00:00
OneWay e423bd6c58 refactor(main): expose cold-start preferences as an InitialLaunch flow
Read the theme, locale, empty-library seed and section expansion
once in the application and collect them as state, instead of
blocking the UI thread with synchronous reads before setContent.
2026-10-04 12:15:00 +00:00
OneWay 0bae625c01 feat(settings): enter multiple dictionary source URLs in one sheet
Allow adding and removing URL fields in the Add Source sheet,
validate the whole form at once, and sanitize entered URLs.
2026-10-04 12:12:00 +00:00
OneWay 344852a871 fix(download): verify payload freshness before restoring the update state
On restart the update-available state was restored purely from the
saved date. Also compare the installed payload file names against
dated sources, and re-check when they no longer match.
2026-10-04 12:09:00 +00:00
OneWay 6146c0ebfe fix(article): cancel link press only after touch slop
An already-consumed down change is part of another gesture, not a
reason to cancel the link press. Only treat movement past touch
slop as cancellation.
2026-10-04 12:06:00 +00:00
OneWay 1202662afe fix(storage): retry index file creation while the target is busy
A just-deleted index file can still be held by the system, so
createNewFile() may fail right after deletion. Retry a few times
with a short delay, waiting for the old file to disappear first.
2026-10-04 12:03:00 +00:00
OneWay c90f658bfe fix(settings): accept dictionary source URLs over https only
The platform blocks cleartext traffic by default, so an http:// source
passed validation but could never download and failed with a misleading
network error. The validator now rejects http:// upfront.
2026-09-27 12:30:00 +08:00
OneWay cd589b30a4 fix(download): don't show the update card for current dictionaries
areDictionariesUpToDate() mapped every exception to NeedsUpdate, and
the post-update recheck ran after the first indexed source of a batch,
before later sources finished downloading; the interim NeedsUpdate
persisted for the day. Treat unexpected errors as up to date, rethrow
cancellation, and recheck once when the download batch completes.
2026-09-27 11:00:00 +08:00
44 changed files with 1041 additions and 390 deletions

No files matched your search

+1
View File
@@ -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.
@@ -26,13 +26,12 @@ import com.example.research.common.ui.AppLocaleProvider
import com.example.research.common.ui.BackgroundTextMeasurementProvider import com.example.research.common.ui.BackgroundTextMeasurementProvider
import com.example.research.common.ui.theme.ReSearchTheme import com.example.research.common.ui.theme.ReSearchTheme
import com.example.research.core.domain.model.AppTheme import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.data.local.preferences.SectionExpansionStates import com.example.research.data.local.preferences.SectionExpansionStates
import com.example.research.feature.search.SearchAction import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel import com.example.research.feature.search.SearchViewModel
import com.example.research.ui.navigation.AppNavigation import com.example.research.ui.navigation.AppNavigation
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 +46,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,11 +69,8 @@ class MainActivity : ComponentActivity() {
app.dictionaryImportManager, app.dictionaryImportManager,
app.dictionaryProgressStateHolder, app.dictionaryProgressStateHolder,
versionName, versionName,
initialSectionExpansion ?: SectionExpansionStates( app.initialLaunch.value?.sectionExpansion ?: SectionExpansionStates(),
isThemeExpanded = false, app.initialLaunch.value?.searchBarPosition ?: SearchBarPosition.TOP,
isLanguageExpanded = false,
isDictionariesExpanded = true,
),
) )
} }
} }
@@ -87,19 +81,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 +106,7 @@ class MainActivity : ComponentActivity() {
AppNavigation( AppNavigation(
searchViewModel = searchViewModel, searchViewModel = searchViewModel,
settingsViewModel = settingsViewModel, settingsViewModel = settingsViewModel,
seedNoDictionaries = initialHadNoDictionaries seedNoDictionaries = launch.seedNoDictionaries
) )
} }
} }
@@ -138,69 +129,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)
@@ -216,7 +144,7 @@ class MainActivity : ComponentActivity() {
val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT) val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)
?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY) ?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY)
text?.toString()?.let { text?.toString()?.let {
searchViewModel.onAction(SearchAction.HandleExternalSearch(it)) searchViewModel.onAction(SearchAction.QueryChanged(it))
} }
} }
} }
@@ -3,7 +3,10 @@ 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.core.domain.model.SearchBarPosition
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 +15,26 @@ 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 searchBarPosition: SearchBarPosition,
val seedNoDictionaries: Boolean,
val sectionExpansion: SectionExpansionStates,
)
class ReSearchApplication : Application() { class ReSearchApplication : Application() {
lateinit var preferencesManager: PreferencesManager lateinit var preferencesManager: PreferencesManager
@@ -43,6 +62,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 +75,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 +146,20 @@ class ReSearchApplication : Application() {
downloadManager.onTerminal = dictionaryPipelineCoordinator::onDownloadTerminal downloadManager.onTerminal = dictionaryPipelineCoordinator::onDownloadTerminal
} }
private suspend fun loadInitialLaunch(): InitialLaunch {
return InitialLaunch(
theme = runCatching { AppTheme.valueOf(preferencesManager.themeMode.first()) }
.getOrDefault(AppTheme.SYSTEM),
language = runCatching { preferencesManager.language.first() }.getOrDefault("system"),
searchBarPosition = runCatching { preferencesManager.searchBarPosition.first() }
.getOrDefault(SearchBarPosition.TOP),
seedNoDictionaries = runCatching { preferencesManager.hadNoDictionaries.first() }
.getOrDefault(true),
sectionExpansion = runCatching { preferencesManager.sectionExpansionStates() }
.getOrDefault(SectionExpansionStates()),
)
}
override fun onTrimMemory(level: Int) { 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)
)
}
}
@@ -1,13 +1,17 @@
package com.example.research.common.ui package com.example.research.common.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
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
fun Modifier.appTestTag(tag: String): Modifier = this fun Modifier.appTestTag(tag: String): Modifier = this
.testTag(tag) .testTag(tag)
@@ -24,3 +28,14 @@ fun Modifier.clickableWithHaptics(
onClick() onClick()
} }
} }
@Composable
fun Modifier.iconClickable(
enabled: Boolean = true,
onClick: () -> Unit
): Modifier = this.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 20.dp),
enabled = enabled,
onClick = onClick
)
@@ -19,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
} }
awaitGone(targetFile::exists)
} 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()
awaitGone(targetFile::exists)
}
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")
@@ -116,7 +130,7 @@ class FileStorageManager {
for (attempt in 1..maxRetries) { for (attempt in 1..maxRetries) {
try { try {
if (file.delete()) { if (file.delete()) {
waitForFileDeletion(file) awaitGone(file::exists)
if (!file.exists()) { if (!file.exists()) {
return@withContext true return@withContext true
} }
@@ -141,9 +155,10 @@ class FileStorageManager {
} }
} }
private suspend fun waitForFileDeletion(file: DocumentFile) { // FUSE-backed storage keeps a just-deleted file visible to stat/open for a short window
private suspend fun awaitGone(exists: () -> Boolean) {
var waitCount = 0 var waitCount = 0
while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) { while (exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
delay(DELETE_WAIT_INTERVAL.milliseconds) delay(DELETE_WAIT_INTERVAL.milliseconds)
waitCount++ waitCount++
} }
@@ -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") &&
@@ -0,0 +1,11 @@
package com.example.research.core.domain.model
enum class SearchBarPosition {
TOP,
BOTTOM;
companion object {
fun fromStorage(value: String?): SearchBarPosition =
entries.firstOrNull { it.name == value } ?: TOP
}
}
@@ -60,7 +60,10 @@ fun String.extractDictionaryPrefix(): String {
} }
fun String.removeAccentTagsForIndexing(): String { fun String.removeAccentTagsForIndexing(): String =
rewriteAccentTags { builder, from, to -> builder.append(this, from, to) }
internal inline fun String.rewriteAccentTags(appendInner: (StringBuilder, Int, Int) -> Unit): String {
val firstOpen = indexOf("[']") val firstOpen = indexOf("[']")
if (firstOpen < 0) return this if (firstOpen < 0) return this
@@ -80,7 +83,7 @@ fun String.removeAccentTagsForIndexing(): String {
builder = StringBuilder(length) builder = StringBuilder(length)
} }
builder.append(this, appendStart, index) builder.append(this, appendStart, index)
builder.append(this, index + 3, scan) appendInner(builder, index + 3, scan)
index = scan + 4 index = scan + 4
appendStart = index appendStart = index
continue continue
@@ -94,7 +97,7 @@ fun String.removeAccentTagsForIndexing(): String {
}?.toString() ?: this }?.toString() ?: this
} }
private fun String.startsWithAt(start: Int, literal: String): Boolean { internal fun String.startsWithAt(start: Int, literal: String): Boolean {
if (start < 0 || start + literal.length > length) return false if (start < 0 || start + literal.length > length) return false
for (i in literal.indices) { for (i in literal.indices) {
if (this[start + i] != literal[i]) return false if (this[start + i] != literal[i]) return false
@@ -9,7 +9,9 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.SearchBarPosition
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -18,9 +20,10 @@ import java.io.File
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
data class SectionExpansionStates( data class SectionExpansionStates(
val isThemeExpanded: Boolean, val isThemeExpanded: Boolean = false,
val isLanguageExpanded: Boolean, val isLanguageExpanded: Boolean = false,
val isDictionariesExpanded: Boolean, val isSearchBarExpanded: Boolean = false,
val isDictionariesExpanded: Boolean = true,
) )
class PreferencesManager(private val context: Context) { class PreferencesManager(private val context: Context) {
@@ -28,12 +31,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 +67,17 @@ class PreferencesManager(private val context: Context) {
} }
} }
val searchBarPosition: Flow<SearchBarPosition> = context.dataStore.data
.map { preferences ->
SearchBarPosition.fromStorage(preferences[PreferencesKeys.SEARCH_BAR_POSITION])
}
suspend fun saveSearchBarPosition(position: SearchBarPosition) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.SEARCH_BAR_POSITION] = position.name
}
}
val dictionarySources: Flow<List<DictionarySource>> = context.dataStore.data 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 +122,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 +165,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 +180,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 +227,51 @@ class PreferencesManager(private val context: Context) {
} }
} }
val searchHistory: Flow<List<String>> = context.dataStore.data
.map { preferences -> preferences[PreferencesKeys.SEARCH_HISTORY] }
.distinctUntilChanged()
.map(::decodeSearchHistory)
suspend fun addSearchHistoryEntry(query: String) {
context.dataStore.edit { preferences ->
val current = decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
.toMutableList()
current.removeAll { it.equals(query, ignoreCase = true) }
current.add(0, query)
preferences[PreferencesKeys.SEARCH_HISTORY] =
json.encodeToString(current.take(MAX_SEARCH_HISTORY_ENTRIES))
}
}
suspend fun removeSearchHistoryEntry(query: String) {
context.dataStore.edit { preferences ->
val current = decodeSearchHistory(preferences[PreferencesKeys.SEARCH_HISTORY])
.toMutableList()
if (current.removeAll { it == query }) {
if (current.isEmpty()) {
preferences.remove(PreferencesKeys.SEARCH_HISTORY)
} else {
preferences[PreferencesKeys.SEARCH_HISTORY] = json.encodeToString(current)
}
}
}
}
suspend fun clearSearchHistory() {
context.dataStore.edit { preferences ->
preferences.remove(PreferencesKeys.SEARCH_HISTORY)
}
}
private fun decodeSearchHistory(value: String?): List<String> {
if (value == null) return emptyList()
return try {
json.decodeFromString<List<String>>(value)
} catch (_: Exception) {
emptyList()
}
}
suspend fun sanitizeDictionarySources() { suspend fun sanitizeDictionarySources() {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
val currentSources = decodeSources( val currentSources = decodeSources(
@@ -1,5 +1,8 @@
package com.example.research.data.parser package com.example.research.data.parser
import com.example.research.core.util.rewriteAccentTags
import com.example.research.core.util.startsWithAt
object DslHeadwordParser { object DslHeadwordParser {
private val FORMATTING_LITERAL_TAGS = arrayOf( private val FORMATTING_LITERAL_TAGS = arrayOf(
@@ -117,12 +120,12 @@ object DslHeadwordParser {
} }
private fun reconstructDisplayPrefix(value: String): String { private fun reconstructDisplayPrefix(value: String): String {
val reconstructed = removeCurlyBraceDelimiters(value) return removeFormattingTags(removeCurlyBraceDelimiters(value).applyAccentMarks()).trim()
val formattingTagIndex = firstFormattingTagIndex(reconstructed) }
return if (formattingTagIndex >= 0) {
reconstructed.substring(0, formattingTagIndex).trim() private fun String.applyAccentMarks(): String = rewriteAccentTags { builder, from, to ->
} else { if (to > from) {
reconstructed.trim() builder.append(this[from]).append('\u0301').append(this, from + 1, to)
} }
} }
@@ -145,21 +148,6 @@ object DslHeadwordParser {
} }
} }
private fun firstFormattingTagIndex(value: String): Int {
var index = 0
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '[' && formattingTagEnd(value, index) > index) {
return index
}
index++
}
return -1
}
private fun firstCurlyContent(value: String): String? { private fun firstCurlyContent(value: String): String? {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
@@ -346,14 +334,6 @@ object DslHeadwordParser {
return if (index < value.length && value[index] == ']') index + 1 else -1 return if (index < value.length && value[index] == ']') index + 1 else -1
} }
private fun String.startsWithAt(start: Int, literal: String): Boolean {
if (start < 0 || start + literal.length > length) return false
for (i in literal.indices) {
if (this[start + i] != literal[i]) return false
}
return true
}
private fun Char.isTagWhitespace(): Boolean { private fun Char.isTagWhitespace(): Boolean {
return this == ' ' || return this == ' ' ||
this == '\t' || this == '\t' ||
@@ -24,7 +24,7 @@ class DslIndexer(context: Context? = null) {
companion object { companion object {
private const val TAG = "DslIndexer" private const val TAG = "DslIndexer"
const val INDEX_VERSION = 6 const val INDEX_VERSION = 7
const val BUFFER_SIZE = 1048576 const val BUFFER_SIZE = 1048576
const val BATCH_BUFFER_SIZE = 524288 const val BATCH_BUFFER_SIZE = 524288
private const val INDEX_SORT_PARALLELISM = 1 private const val INDEX_SORT_PARALLELISM = 1
@@ -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 */ }
@@ -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
} }
} }
@@ -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)
} }
} }
} }
@@ -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)
} }
} }
} }
@@ -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
@@ -28,12 +29,12 @@ sealed interface SearchAction {
data class ArticleClicked(val entry: IndexEntry) : SearchAction data class ArticleClicked(val entry: IndexEntry) : SearchAction
data object ClearArticle : SearchAction data object ClearArticle : SearchAction
data class SearchAndLoadArticle(val word: String) : SearchAction data class SearchAndLoadArticle(val word: String) : SearchAction
data class HandleExternalSearch(val text: String) : 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 +52,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 +69,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,11 +136,11 @@ 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()
is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word) is SearchAction.SearchAndLoadArticle -> searchAndLoadArticle(action.word)
is SearchAction.HandleExternalSearch -> handleExternalSearch(action.text)
} }
} }
@@ -159,6 +169,27 @@ 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
if (searchHistory.value.firstOrNull() == trimmed) 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 +312,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 ->
@@ -289,14 +321,6 @@ class SearchViewModel(
} }
} }
fun handleExternalSearch(text: String) {
viewModelScope.launch {
val sanitizedText = text.sanitizeQuery()
mutableSearchQuery.value = sanitizedText
searchAndLoadArticle(sanitizedText)
}
}
companion object { companion object {
private const val TAG = "SearchViewModel" private const val TAG = "SearchViewModel"
@@ -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
@@ -161,13 +156,14 @@ private fun AboutHeader(versionName: String) {
Text( Text(
text = stringResource(R.string.app_name), text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.alignByBaseline()
) )
Text( Text(
text = stringResource(R.string.version_format, versionName), text = versionName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 2.dp) modifier = Modifier.alignByBaseline()
) )
} }
@@ -186,7 +182,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?,
@@ -14,7 +14,7 @@ import com.example.research.core.domain.model.IndexEntry
import com.example.research.ui.article.ArticleContent import com.example.research.ui.article.ArticleContent
import com.example.research.ui.article.ArticleTitleBar import com.example.research.ui.article.ArticleTitleBar
import com.example.research.ui.article.ArticleState import com.example.research.ui.article.ArticleState
import com.example.research.ui.main.components.MainTopBar import com.example.research.ui.theme.AppWindowInsets
import com.example.research.ui.theme.Spacing import com.example.research.ui.theme.Spacing
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -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,37 @@ 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( MainSearchBar(
searchState = searchState, searchState = searchState,
enabled = canSearch, canSearch = canSearch,
onQueryChange = { onAction(MainAction.QueryChanged(it)) }, isSearchBarAtBottom = isSearchBarAtBottom,
onClearQuery = { onAction(MainAction.ClearQuery) }, searchHistory = searchHistory,
onNavigateToSettings = onNavigateToSettings onAction = onAction,
onNavigateToSettings = onNavigateToSettings,
) )
}
Column(
modifier = Modifier
.fillMaxSize()
.then(
if (isSearchBarAtBottom) {
Modifier.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
} else {
Modifier
}
)
) {
if (!isSearchBarAtBottom) {
searchBar()
}
ListDetailPaneScaffold( ListDetailPaneScaffold(
directive = PaneScaffoldDirective.Default.copy( directive = PaneScaffoldDirective.Default.copy(
@@ -120,26 +138,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,9 @@
package com.example.research.ui.main package com.example.research.ui.main
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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
@@ -20,6 +22,7 @@ import androidx.paging.compose.collectAsLazyPagingItems
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.feature.download.config.DictionaryConfig import com.example.research.feature.download.config.DictionaryConfig
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.feature.download.model.DownloadState import com.example.research.feature.download.model.DownloadState
import com.example.research.feature.search.SearchAction import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel import com.example.research.feature.search.SearchViewModel
@@ -37,9 +40,11 @@ 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()
val isSearchBarAtBottom = settingsState.searchBarPosition == SearchBarPosition.BOTTOM
val scrollPosition = remember(searchViewModel) { val scrollPosition = remember(searchViewModel) {
searchViewModel.scrollPosition.value searchViewModel.scrollPosition.value
@@ -47,17 +52,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 +73,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 +83,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 +134,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,
@@ -133,28 +148,81 @@ fun MainRoute(
} }
} }
@Composable
internal fun MainSearchBar(
searchState: TextFieldState,
canSearch: Boolean,
isSearchBarAtBottom: Boolean,
searchHistory: List<String>,
onAction: (MainAction) -> Unit,
onNavigateToSettings: () -> Unit,
) {
MainTopBar(
searchState = searchState,
enabled = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
searchHistory = searchHistory,
onHistoryEntryClick = { onAction(MainAction.HistoryEntryClicked(it)) },
onHistoryEntryRemove = { onAction(MainAction.RemoveHistoryEntry(it)) },
onClearHistory = { onAction(MainAction.ClearHistory) },
onNavigateToSettings = onNavigateToSettings
)
}
@Composable @Composable
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 = { MainSearchBar(
MainTopBar( searchState = searchState,
canSearch = canSearch,
isSearchBarAtBottom = isSearchBarAtBottom,
searchHistory = searchHistory,
onAction = onAction,
onNavigateToSettings = onNavigateToSettings,
)
}
if (isSearchBarAtBottom) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(AppWindowInsets.statusBarsAndIme)
) {
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,14 @@
package com.example.research.ui.main.components package com.example.research.ui.main.components
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.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.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 +22,106 @@ 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.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.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.res.painterResource
import androidx.compose.ui.res.stringResource
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 com.example.research.R import com.example.research.R
import com.example.research.common.ui.appTestTag
import com.example.research.common.ui.iconClickable
import com.example.research.common.ui.theme.dictionaryTitleLarge import com.example.research.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
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(AppWindowInsets.bottomNavigation)
} 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 +131,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 +168,39 @@ fun MainTopBar(
} }
} }
} }
if (showHistorySheet && searchHistory.isNotEmpty()) {
SearchHistorySheet(
history = searchHistory,
isSearchBarAtBottom = isSearchBarAtBottom,
topBarBottomPx = topBarBottomPx,
onDismiss = { showHistorySheet = false },
onEntryClick = { entry ->
focusManager.clearFocus(force = true)
showHistorySheet = false
onHistoryEntryClick(entry)
},
onEntryRemove = onHistoryEntryRemove,
onClearAll = {
showHistorySheet = false
onClearHistory()
}
)
}
} }
@Composable @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 +223,16 @@ 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)
.appTestTag("history_button")
.iconClickable(enabled = historyEnabled, onClick = onShowHistory)
) )
}, },
trailingIcon = if (searchState.text.isNotEmpty()) { trailingIcon = if (searchState.text.isNotEmpty()) {
@@ -183,3 +277,47 @@ 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
val navigationBarsBottom = WindowInsets.navigationBars.getBottom(density)
val maxContentHeight = remember {
with(density) {
if (isSearchBarAtBottom) {
screenHeight - HistorySheetChrome - navigationBarsBottom.toDp()
} else {
screenHeight - topBarBottomPx.toDp()
}
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
modifier = Modifier.testTag("search_history_sheet")
) {
SearchHistoryList(
history = history,
onEntryClick = onEntryClick,
onEntryRemove = onEntryRemove,
onClearAll = onClearAll,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = HistorySheetMinHeight, max = maxContentHeight)
.padding(horizontal = 24.dp)
)
}
}
@@ -0,0 +1,138 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.appTestTag
import com.example.research.common.ui.iconClickable
import com.example.research.common.ui.theme.dictionaryTitleLarge
import com.example.research.ui.theme.Spacing
@Composable
fun SearchHistoryList(
history: List<String>,
onEntryClick: (String) -> Unit,
onEntryRemove: (String) -> Unit,
onClearAll: () -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(),
) {
LazyColumn(
modifier = modifier
.appTestTag("search_history"),
state = listState,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
item(key = "history_header", contentType = "history_header") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Spacing.listItemVertical),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.recent_searches),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.clear_search_history),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
softWrap = false,
modifier = Modifier
.padding(end = 18.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.appTestTag("clear_history")
.clickable { onClearAll() }
.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
items(
items = history,
key = { it },
contentType = { "history_entry" }
) { entry ->
SearchHistoryItem(
entry = entry,
onEntryClick = onEntryClick,
onEntryRemove = onEntryRemove
)
}
}
}
@Composable
private fun SearchHistoryItem(
entry: String,
onEntryClick: (String) -> Unit,
onEntryRemove: (String) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.appTestTag("search_history_entry")
.clickable { onEntryClick(entry) }
.padding(vertical = Spacing.listItemVertical),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_history),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(end = 16.dp)
.size(20.dp)
)
Text(
text = entry,
style = MaterialTheme.typography.dictionaryTitleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.width(Spacing.topBarHeight)
) {
Icon(
painter = painterResource(R.drawable.ic_close),
contentDescription = stringResource(R.string.remove_search_history_entry),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.appTestTag("remove_history_entry")
.iconClickable { onEntryRemove(entry) }
.size(20.dp)
)
}
}
}
@@ -1,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))
} }
@@ -3,6 +3,7 @@ package com.example.research.ui.settings
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.example.research.core.domain.model.AppTheme import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress import com.example.research.core.domain.model.IndexingProgress
@@ -13,8 +14,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: SearchBarPosition = SearchBarPosition.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,
@@ -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: SearchBarPosition) : 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
@@ -9,6 +9,7 @@ import com.example.research.R
import com.example.research.common.progress.DictionaryProgressStateHolder import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.common.util.DateUtils import com.example.research.common.util.DateUtils
import com.example.research.core.domain.model.AppTheme import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.SearchBarPosition
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress import com.example.research.core.domain.model.IndexingProgress
@@ -46,11 +47,13 @@ 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: SearchBarPosition,
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,
) )
@@ -64,6 +67,7 @@ class SettingsViewModel(
progressStateHolder: DictionaryProgressStateHolder, progressStateHolder: DictionaryProgressStateHolder,
appVersion: String, appVersion: String,
initialSectionExpansion: SectionExpansionStates, initialSectionExpansion: SectionExpansionStates,
initialSearchBarPosition: SearchBarPosition,
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase( private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
@@ -76,7 +80,9 @@ class SettingsViewModel(
appVersion = appVersion, appVersion = appVersion,
isThemeExpanded = initialSectionExpansion.isThemeExpanded, isThemeExpanded = initialSectionExpansion.isThemeExpanded,
isLanguageExpanded = initialSectionExpansion.isLanguageExpanded, isLanguageExpanded = initialSectionExpansion.isLanguageExpanded,
isSearchBarExpanded = initialSectionExpansion.isSearchBarExpanded,
isDictionariesExpanded = initialSectionExpansion.isDictionariesExpanded, isDictionariesExpanded = initialSectionExpansion.isDictionariesExpanded,
searchBarPosition = initialSearchBarPosition,
) )
) )
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
@@ -96,6 +102,7 @@ class SettingsViewModel(
private var cancelRefreshPending = false private var cancelRefreshPending = false
private var errorStateHandled = false private var errorStateHandled = false
private var successStateHandled = false
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
init { init {
@@ -133,8 +140,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,47 +158,48 @@ class SettingsViewModel(
val operationStateInputs = combine( val operationStateInputs = combine(
downloadManager.downloadState, downloadManager.downloadState,
language, language,
preferencesManager.searchBarPosition,
dictionaryImportManager.importState, dictionaryImportManager.importState,
dictionaryStatus, dictionaryStatus,
hasCompletedStartupScan, hasCompletedStartupScan,
preferencesManager.isThemeExpanded, preferencesManager.isThemeExpanded,
preferencesManager.isLanguageExpanded, preferencesManager.isLanguageExpanded,
preferencesManager.isSearchBarExpanded,
preferencesManager.isDictionariesExpanded, preferencesManager.isDictionariesExpanded,
) { 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 SearchBarPosition,
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,
) )
} }
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
cancelRefreshPending = false cancelRefreshPending = false
errorStateHandled = false errorStateHandled = false
successStateHandled = false
} }
is DownloadState.Success -> { is DownloadState.Success -> {
pendingSourceUrls.clear() pendingSourceUrls.clear()
isDownloadInProgress = false
if (!successStateHandled) {
successStateHandled = true
dictionaryStatus.value = withContext(Dispatchers.IO) {
evaluateDictionaryStatus()
}
}
} }
is DownloadState.Error -> { is DownloadState.Error -> {
isDownloadInProgress = false isDownloadInProgress = false
@@ -251,6 +257,7 @@ 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,
@@ -259,6 +266,7 @@ class SettingsViewModel(
!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 +277,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()) {
@@ -288,9 +302,11 @@ class SettingsViewModel(
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}") android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
} }
hasCompletedStartupScan.value = true hasCompletedStartupScan.value = true
preferencesManager.setHadNoDictionaries( if (scanCompleted) {
localDictionaryRepository.dictionaries.value.isEmpty() preferencesManager.setHadNoDictionaries(
) localDictionaryRepository.dictionaries.value.isEmpty()
)
}
} }
} }
} }
@@ -299,6 +315,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 +388,12 @@ class SettingsViewModel(
} }
} }
private fun saveSearchBarPosition(position: SearchBarPosition) {
viewModelScope.launch {
preferencesManager.saveSearchBarPosition(position)
}
}
private fun saveLanguage(languageCode: String) { private fun saveLanguage(languageCode: String) {
viewModelScope.launch { viewModelScope.launch {
preferencesManager.saveLanguage(languageCode) preferencesManager.saveLanguage(languageCode)
@@ -528,6 +551,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 +630,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
@@ -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,
@@ -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,29 @@ private fun validateDictionaryUrl(context: UrlValidationContext): String? {
} }
} }
private val urlSplitRegex = Regex("[\\s,;]+")
private fun splitPastedUrls(text: String): List<String>? =
text.split(urlSplitRegex).filter { it.isNotEmpty() }.takeIf { tokens ->
tokens.size > 1 && tokens.all {
DictionarySource.validateTemplate(it) is DictionarySource.ValidationResult.Valid
}
}
private val urlInputTransformation = InputTransformation {
val text = toString()
val filtered = text
.take(2048)
.filter { char ->
!char.isISOControl() &&
char != '\u0000' &&
char.code !in 128..<160
}
if (filtered != text) {
replace(0, length, filtered)
}
}
@Composable @Composable
fun DictionaryUrlInputContent( fun DictionaryUrlInputContent(
existingSources: List<DictionarySource>, existingSources: List<DictionarySource>,
@@ -71,11 +96,22 @@ 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
LaunchedEffect(Unit) {
snapshotFlow { urls.map { it.text.toString() } }.collect { texts ->
val (index, tokens) = texts.withIndex().firstNotNullOfOrNull { (i, text) ->
splitPastedUrls(text)?.let { i to it }
} ?: return@collect
urls[index].edit { replace(0, length, tokens.first()) }
urls.addAll(index + 1, tokens.subList(1, tokens.size).map { TextFieldState(it) })
validationErrors.clear()
}
}
val errorMessages = ErrorMessages( val errorMessages = ErrorMessages(
empty = stringResource(R.string.dictionary_source_url_empty), empty = stringResource(R.string.dictionary_source_url_empty),
duplicateInForm = stringResource(R.string.dictionary_source_duplicate_in_form), duplicateInForm = stringResource(R.string.dictionary_source_duplicate_in_form),
@@ -96,8 +132,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.trimmedText()
urlString to if (urlString.isNotEmpty()) { urlString to if (urlString.isNotEmpty()) {
DictionarySource.extractPrefix(urlString) DictionarySource.extractPrefix(urlString)
} else null } else null
@@ -119,10 +155,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.trimmedText()
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 +166,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.trimmedText() },
urlPrefix = cachedPrefix, urlPrefix = cachedPrefix,
errorMessages = errorMessages errorMessages = errorMessages
) )
@@ -140,20 +176,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 +192,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 +212,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 +248,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.trimmedText()
if (urlString.isBlank()) { if (urlString.isBlank()) {
if (urls.size == 1) { if (urls.size == 1) {
validationErrors[index] = errorMessages.empty validationErrors[index] = errorMessages.empty
@@ -263,9 +287,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.trimmedText().isNotBlank() } &&
urls.none { urlValue -> urls.none { urlState ->
val urlString = urlValue.text.trim() val urlString = urlState.trimmedText()
if (urlString.isEmpty()) { if (urlString.isEmpty()) {
false false
} else { } else {
@@ -275,7 +299,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.trimmedText() },
urlPrefix = urlPrefix, urlPrefix = urlPrefix,
errorMessages = errorMessages errorMessages = errorMessages
) )
@@ -289,3 +313,5 @@ fun DictionaryUrlInputContent(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
} }
private fun TextFieldState.trimmedText(): String = text.trim().toString()
@@ -0,0 +1,40 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.core.domain.model.SearchBarPosition
@Composable
fun SearchBarPositionSection(
currentPosition: SearchBarPosition,
onPositionChanged: (SearchBarPosition) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
SearchBarPosition.entries.forEach { position ->
val isSelected = currentPosition == position
OutlinedChoiceButton(
text = when (position) {
SearchBarPosition.TOP -> stringResource(R.string.search_bar_position_top)
SearchBarPosition.BOTTOM -> stringResource(R.string.search_bar_position_bottom)
},
onClick = { onPositionChanged(position) },
modifier = Modifier
.weight(1f)
.testTag("search_bar_${position.name}"),
selected = isSelected
)
}
}
}
@@ -6,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
@@ -24,12 +24,20 @@ object AppWindowInsets {
@Composable @Composable
get() = WindowInsets.navigationBars.union(WindowInsets.ime) get() = WindowInsets.navigationBars.union(WindowInsets.ime)
val statusBarsAndIme: WindowInsets
@Composable
get() = WindowInsets.statusBars.union(WindowInsets.ime)
val fullScreen: WindowInsets val fullScreen: WindowInsets
@Composable @Composable
get() = WindowInsets.statusBars get() = WindowInsets.statusBars
.union(WindowInsets.navigationBars) .union(WindowInsets.navigationBars)
.union(WindowInsets.displayCutout) .union(WindowInsets.displayCutout)
val bottomNavigation: WindowInsets
@Composable
get() = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
val navigationOnly: WindowInsets val navigationOnly: WindowInsets
@Composable @Composable
get() = WindowInsets.navigationBars get() = WindowInsets.navigationBars
+9
View File
@@ -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>
+7 -2
View File
@@ -2,7 +2,6 @@
<string name="app_name">ReSearch</string> <string name="app_name">ReSearch</string>
<string name="settings_title">Настройки</string> <string name="settings_title">Настройки</string>
<string name="action_back">Назад</string> <string name="action_back">Назад</string>
<string name="version_format">v%1$s</string>
<string name="version_unknown">неизвестно</string> <string name="version_unknown">неизвестно</string>
<string name="button_cancel_download">Отмена</string> <string name="button_cancel_download">Отмена</string>
<string name="action_ok">ОК</string> <string name="action_ok">ОК</string>
@@ -12,7 +11,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 +81,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>
+7 -2
View File
@@ -2,7 +2,6 @@
<string name="app_name">ReSearch</string> <string name="app_name">ReSearch</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="action_back">Back</string> <string name="action_back">Back</string>
<string name="version_format">v%1$s</string>
<string name="version_unknown">unknown</string> <string name="version_unknown">unknown</string>
<string name="button_cancel_download">Cancel</string> <string name="button_cancel_download">Cancel</string>
<string name="action_ok">OK</string> <string name="action_ok">OK</string>
@@ -12,7 +11,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 +79,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>