Initial ReSearch export

This commit is contained in:
OneWay committed 2026-07-06 09:29:50 +08:00
commit e8a49575c1
145 files changed
+14188

No files matched your search

@@ -0,0 +1,83 @@
package com.example.research.ui.about
import android.content.Context
import androidx.annotation.RawRes
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
internal data class AboutLibrariesData(
val libraries: List<AboutLibrary> = emptyList(),
val licenses: Map<String, AboutLicense> = emptyMap(),
)
@Serializable
internal data class AboutLibrary(
val uniqueId: String,
val artifactVersion: String? = null,
val name: String,
val description: String? = null,
val website: String? = null,
val developers: List<AboutDeveloper> = emptyList(),
val organization: AboutOrganization? = null,
val scm: AboutScm? = null,
val licenses: List<String> = emptyList(),
) {
val author: String = run {
val developerNames = developers
.asSequence()
.map(AboutDeveloper::name)
.filter(String::isNotBlank)
.distinct()
.filterNot { it == GENERIC_ANDROID_AUTHOR }
.joinToString()
developerNames.ifBlank {
organization?.name
?.takeUnless { it == GENERIC_ANDROID_AUTHOR }
.orEmpty()
}
}
private companion object {
private const val GENERIC_ANDROID_AUTHOR = "The Android Open Source Project"
}
}
@Serializable
internal data class AboutDeveloper(
val name: String = "",
)
@Serializable
internal data class AboutOrganization(
val name: String = "",
)
@Serializable
internal data class AboutScm(
val url: String? = null,
)
@Serializable
internal data class AboutLicense(
val name: String,
val url: String? = null,
val content: String? = null,
@SerialName("spdxId")
val spdxId: String? = null,
)
internal object AboutLibrariesParser {
private val json = Json {
ignoreUnknownKeys = true
}
fun read(context: Context, @RawRes resourceId: Int): AboutLibrariesData =
context.resources.openRawResource(resourceId)
.bufferedReader()
.use { reader -> json.decodeFromString(reader.readText()) }
fun decode(source: String): AboutLibrariesData = json.decodeFromString(source)
}
@@ -0,0 +1,319 @@
package com.example.research.ui.about
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.ui.theme.AppWindowInsets
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun AboutScreen(
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
val packageInfo = remember(context) {
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0)
}.getOrNull()
}
val versionName = packageInfo?.versionName ?: stringResource(R.string.version_unknown)
val uriHandler = LocalUriHandler.current
val librariesData = remember(context) {
AboutLibrariesParser.read(context, R.raw.aboutlibraries)
}
var expandedLibraryId by remember { mutableStateOf<String?>(null) }
var dialogLicense by remember { mutableStateOf<AboutLicense?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.about_app_title)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back),
contentDescription = stringResource(R.string.action_back)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
contentWindowInsets = AppWindowInsets.fullScreen
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.testTag("about_libraries_list")
.semantics { testTagsAsResourceId = true }
.padding(padding)
) {
item(key = "about_header") {
AboutHeader(versionName = versionName)
}
items(
items = librariesData.libraries,
key = AboutLibrary::uniqueId,
) { library ->
AboutLibraryRow(
library = library,
licenses = librariesData.licenses,
expanded = expandedLibraryId == library.uniqueId,
onToggle = {
expandedLibraryId = if (expandedLibraryId == library.uniqueId) {
null
} else {
library.uniqueId
}
},
onLicenseClick = { dialogLicense = it },
)
}
}
}
dialogLicense?.let { license ->
LicenseDialog(
license = license,
onDismiss = { dialogLicense = null },
onOpenWebsite = { url -> uriHandler.openUri(url) },
)
}
}
@Composable
private fun AboutHeader(versionName: String) {
val uriHandler = LocalUriHandler.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.version_format, versionName),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 2.dp)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedChoiceButton(
text = stringResource(R.string.website),
onClick = { uriHandler.openUri("https://oneway.asia/") },
fillWidth = false
)
OutlinedChoiceButton(
text = stringResource(R.string.changelog),
onClick = { uriHandler.openUri("https://git.oneway.asia/OneWay/ReSearch") },
fillWidth = false
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AboutLibraryRow(
library: AboutLibrary,
licenses: Map<String, AboutLicense>,
expanded: Boolean,
onToggle: () -> Unit,
onLicenseClick: (AboutLicense) -> Unit,
) {
val uriHandler = LocalUriHandler.current
val website = library.website?.takeIf(String::isNotBlank)
val source = library.scm?.url?.takeIf(String::isNotBlank)
Surface(
modifier = Modifier
.fillMaxWidth()
.testTag("about_library_row")
.semantics { testTagsAsResourceId = true },
color = if (expanded) {
MaterialTheme.colorScheme.surfaceContainerHighest
} else {
MaterialTheme.colorScheme.surface
},
onClick = onToggle
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp)
.animateContentSize(),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = library.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
library.artifactVersion?.takeIf(String::isNotBlank)?.let { version ->
Text(
text = version,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
if (library.author.isNotBlank()) {
Text(
text = library.author,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
library.description?.takeIf(String::isNotBlank)?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
if (library.licenses.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
library.licenses.forEach { licenseId ->
val license = licenses[licenseId] ?: return@forEach
OutlinedChoiceButton(
text = license.name,
onClick = { onLicenseClick(license) },
fillWidth = false,
highlighted = true,
enforceMinimumInteractiveSize = false,
modifier = Modifier.testTag("about_license_button")
)
}
}
}
if (expanded && (website != null || source != null)) {
FlowRow(
modifier = Modifier
.padding(top = 2.dp)
.testTag("about_library_expanded_actions")
.semantics { testTagsAsResourceId = true },
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
source?.let { url ->
OutlinedChoiceButton(
text = stringResource(R.string.source),
onClick = { uriHandler.openUri(url) },
fillWidth = false,
enforceMinimumInteractiveSize = false
)
}
website?.let { url ->
OutlinedChoiceButton(
text = stringResource(R.string.website),
onClick = { uriHandler.openUri(url) },
fillWidth = false,
enforceMinimumInteractiveSize = false
)
}
}
}
}
}
}
@Composable
private fun LicenseDialog(
license: AboutLicense,
onDismiss: () -> Unit,
onOpenWebsite: (String) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(license.name) },
text = {
Text(
text = license.content ?: stringResource(R.string.license_text_unavailable),
modifier = Modifier.verticalScroll(rememberScrollState())
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_close))
}
},
dismissButton = {
license.url?.takeIf(String::isNotBlank)?.let { url ->
TextButton(onClick = { onOpenWebsite(url) }) {
Text(stringResource(R.string.website))
}
}
}
)
}
@@ -0,0 +1,253 @@
package com.example.research.ui.article
import android.accessibilityservice.AccessibilityServiceInfo
import android.view.accessibility.AccessibilityManager
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.core.content.getSystemService
import com.example.research.BuildConfig
internal val LocalForceStockArticleLinks = compositionLocalOf { false }
internal val ArticleReferenceAtOffsetAction =
SemanticsPropertyKey<(Int) -> Boolean>("ArticleReferenceAtOffsetAction")
internal var SemanticsPropertyReceiver.articleReferenceAtOffsetAction
by ArticleReferenceAtOffsetAction
internal data class ArticleReferenceTouchTarget(
val word: String,
val center: Offset,
)
internal val ArticleReferenceTouchTargets =
SemanticsPropertyKey<List<ArticleReferenceTouchTarget>>("ArticleReferenceTouchTargets")
internal var SemanticsPropertyReceiver.articleReferenceTouchTargets
by ArticleReferenceTouchTargets
@Composable
internal fun ArticleLinkText(
block: DslBlock,
linksEnabled: Boolean,
style: TextStyle,
onRefClick: (String) -> Unit,
onTextLayout: (TextLayoutResult) -> Unit,
modifier: Modifier = Modifier,
preferDirectHitTesting: Boolean = false,
) {
val accessibilityEnabled = rememberAssistiveAccessibilityEnabled()
val inputMode = LocalInputModeManager.current.inputMode
val forceStockLinks = LocalForceStockArticleLinks.current
val useDirectLinkHitTesting =
linksEnabled &&
block.lazyRefs.isNotEmpty() &&
(preferDirectHitTesting ||
block.lazyRefs.size >= DIRECT_LINK_HIT_TEST_THRESHOLD) &&
!accessibilityEnabled &&
inputMode != InputMode.Keyboard &&
!forceStockLinks
val currentOnRefClick by rememberUpdatedState(onRefClick)
val layoutResult = remember { arrayOfNulls<TextLayoutResult>(1) }
var debugTouchTargets by remember(block) {
mutableStateOf(emptyList<ArticleReferenceTouchTarget>())
}
// Use stable key for remember to avoid unnecessary recomposition
val text = if (linksEnabled && !useDirectLinkHitTesting) {
remember(block, System.identityHashCode(onRefClick)) {
DslAnnotatedParser.applyLazyRefs(block, onRefClick)
}
} else {
block.text
}
val linkModifier = if (useDirectLinkHitTesting) {
Modifier.pointerInput(block) {
detectReferenceTaps(
block = block,
layoutResult = { layoutResult[0] },
onRefClick = { currentOnRefClick(it) },
)
}
} else {
Modifier
}
val debugSemanticsModifier =
if (BuildConfig.DEBUG && linksEnabled && !forceStockLinks && block.lazyRefs.isNotEmpty()) {
Modifier.semantics {
articleReferenceAtOffsetAction = { offset ->
DslAnnotatedParser.referenceAt(block, offset)
?.also { currentOnRefClick(it.word) } != null
}
articleReferenceTouchTargets = debugTouchTargets
}
} else {
Modifier
}
BasicText(
text = text,
style = style,
onTextLayout = { result ->
layoutResult[0] = result
if (BuildConfig.DEBUG && linksEnabled && !forceStockLinks) {
val targets = block.lazyRefs
.mapNotNull { reference ->
reference.startIndex
.takeIf { it in result.layoutInput.text.indices }
?.let { offset ->
ArticleReferenceTouchTarget(
word = reference.word,
center = result.getBoundingBox(offset).center,
)
}
}
if (debugTouchTargets != targets) {
debugTouchTargets = targets
}
}
onTextLayout(result)
},
modifier = modifier
.then(linkModifier)
.then(debugSemanticsModifier),
)
}
@Composable
private fun rememberAssistiveAccessibilityEnabled(): Boolean {
val context = LocalContext.current
val manager = remember(context) { context.getSystemService<AccessibilityManager>() }
var enabled by remember(manager) {
mutableStateOf(manager?.hasAssistiveServiceEnabled() == true)
}
DisposableEffect(manager) {
if (manager == null) {
onDispose { }
} else {
val updateState = {
enabled = manager.hasAssistiveServiceEnabled()
}
val accessibilityListener =
AccessibilityManager.AccessibilityStateChangeListener {
updateState()
}
val touchExplorationListener =
AccessibilityManager.TouchExplorationStateChangeListener {
updateState()
}
manager.addAccessibilityStateChangeListener(accessibilityListener)
manager.addTouchExplorationStateChangeListener(touchExplorationListener)
updateState()
onDispose {
manager.removeAccessibilityStateChangeListener(accessibilityListener)
manager.removeTouchExplorationStateChangeListener(touchExplorationListener)
}
}
}
return enabled
}
private fun AccessibilityManager.hasAssistiveServiceEnabled(): Boolean =
isTouchExplorationEnabled ||
getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK).isNotEmpty()
private suspend fun PointerInputScope.detectReferenceTaps(
block: DslBlock,
layoutResult: () -> TextLayoutResult?,
onRefClick: (String) -> Unit,
) {
awaitEachGesture {
val down = awaitFirstDown(
requireUnconsumed = false,
pass = PointerEventPass.Final,
)
val pointerId = down.id
val downPosition = down.position
val downTime = down.uptimeMillis
var canceled = false
var tapPosition: Offset? = null
while (true) {
val event = awaitPointerEvent(PointerEventPass.Final)
val change = event.changes.firstOrNull { it.id == pointerId }
if (change == null) {
break
}
if (change.isConsumed ||
(change.position - downPosition).getDistance() > viewConfiguration.touchSlop
) {
canceled = true
}
if (!change.pressed) {
if (!canceled &&
change.uptimeMillis - downTime < viewConfiguration.longPressTimeoutMillis
) {
tapPosition = change.position
}
break
}
}
val position = tapPosition ?: return@awaitEachGesture
val result = layoutResult() ?: return@awaitEachGesture
result.referenceAtPosition(block, position, viewConfiguration.touchSlop / 2f)
?.let { onRefClick(it.word) }
}
}
private fun TextLayoutResult.referenceAtPosition(
block: DslBlock,
position: Offset,
hitSlop: Float,
): DslAnnotatedParser.LazyRef? {
if (layoutInput.text.isEmpty() ||
position.x < 0f ||
position.y < 0f ||
position.x > size.width ||
position.y > size.height
) {
return null
}
val text = layoutInput.text
val cursor = getOffsetForPosition(position)
for (candidate in intArrayOf(cursor, cursor - 1)) {
if (candidate < 0 || candidate > text.lastIndex) continue
val glyphStart = if (text[candidate].isLowSurrogate()) candidate - 1 else candidate
if (glyphStart < 0) continue
val reference = DslAnnotatedParser.referenceAt(block, glyphStart) ?: continue
val bounds = getBoundingBox(glyphStart)
if (position.x >= bounds.left - hitSlop &&
position.x <= bounds.right + hitSlop &&
position.y >= bounds.top - hitSlop &&
position.y <= bounds.bottom + hitSlop
) {
return reference
}
}
return null
}
private const val DIRECT_LINK_HIT_TEST_THRESHOLD = 100
@@ -0,0 +1,301 @@
package com.example.research.ui.article
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.R
import com.example.research.feature.search.SearchAction
import com.example.research.ui.theme.Spacing
import com.example.research.ui.theme.AppWindowInsets
import com.example.research.feature.search.SearchViewModel
import androidx.compose.runtime.getValue
@Composable
fun ArticleRoute(
searchViewModel: SearchViewModel,
onNavigateBack: () -> Unit
) {
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
ArticleScreen(
articleState = articleState,
onNavigateBack = onNavigateBack,
onRefClick = { word ->
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(word))
},
onScrollPositionChanged = { index, offset ->
searchViewModel.updateArticleScrollPosition(index, offset)
},
)
}
@Composable
fun ArticleContent(
articleState: ArticleState?,
modifier: Modifier = Modifier,
onNavigateBack: (() -> Unit)? = null,
onRefClick: ((String) -> Unit)? = null,
onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
) {
when (articleState) {
is ArticleState.Loading -> {
Box(modifier = modifier.fillMaxSize())
}
is ArticleState.Success -> {
val articleSaveKey = "${articleState.dictionaryPath}:${articleState.title}"
val baseTextStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface
)
val indentStep = 16.dp
// Use scroll position from ArticleState if available (for back navigation)
val initialScrollIndex = articleState.scrollIndex
val initialScrollOffset = articleState.scrollOffset
val savedScrollIndex = rememberSaveable(articleSaveKey) {
mutableIntStateOf(initialScrollIndex)
}
val savedScrollOffset = rememberSaveable(articleSaveKey) {
mutableIntStateOf(initialScrollOffset)
}
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = savedScrollIndex.intValue,
initialFirstVisibleItemScrollOffset = savedScrollOffset.intValue,
)
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
val currentOnRefClick = rememberUpdatedState(onRefClick)
val currentOnScrollPositionChanged = rememberUpdatedState(onScrollPositionChanged)
val refClickHandler = remember {
{ word: String ->
currentOnRefClick.value?.invoke(word)
Unit
}
}
LaunchedEffect(listState, articleSaveKey) {
var sawScrollInProgress = false
snapshotFlow { listState.isScrollInProgress }
.collect { isScrolling ->
if (isScrolling) {
sawScrollInProgress = true
} else if (sawScrollInProgress) {
val index = listState.firstVisibleItemIndex
val offset = listState.firstVisibleItemScrollOffset
savedScrollIndex.intValue = index
savedScrollOffset.intValue = offset
currentOnScrollPositionChanged.value?.invoke(index, offset)
sawScrollInProgress = false
}
}
}
LaunchedEffect(viewportSize, articleSaveKey) {
if (viewportSize != IntSize.Zero &&
(savedScrollIndex.intValue != 0 ||
savedScrollOffset.intValue != 0)
) {
withFrameNanos { }
listState.scrollToItem(
savedScrollIndex.intValue,
savedScrollOffset.intValue,
)
}
}
val isHeavyArticle =
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
LazyColumn(
state = listState,
modifier = modifier
.fillMaxSize()
.onSizeChanged { viewportSize = it }
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(end = 16.dp)
.testTag("article_content")
.semantics {
testTagsAsResourceId = true
}
) {
itemsIndexed(
items = articleState.dslContent,
key = { index, _ ->
// Use stable key combining article save key and index
"$articleSaveKey:$index"
},
contentType = { _, _ -> "article_block" }
) { index, block ->
val followedByContinuation =
articleState.dslContent.getOrNull(index + 1)?.continuesPrevious == true
val textContent = @Composable {
Box(
modifier = Modifier
.padding(
start = Spacing.iconStart + indentStep * block.indent
)
.padding(bottom = if (followedByContinuation) 0.dp else 8.dp)
) {
ArticleLinkText(
block = block,
linksEnabled = true,
preferDirectHitTesting = isHeavyArticle,
style = baseTextStyle,
onRefClick = refClickHandler,
onTextLayout = {},
modifier = Modifier
.testTag("article_block_$index")
.semantics {
testTagsAsResourceId = true
}
)
}
}
if (isHeavyArticle) {
textContent()
} else {
SelectionContainer { textContent() }
}
}
}
}
is ArticleState.Error -> {
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = articleState.message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onNavigateBack ?: {}) {
Text(stringResource(R.string.article_return_to_search))
}
}
}
null -> {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.article_no_selected),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
@Composable
fun ArticleTitleBar(
articleState: ArticleState?,
showBackButton: Boolean,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier
) {
val title = when (articleState) {
is ArticleState.Loading -> articleState.title
is ArticleState.Success -> articleState.title
is ArticleState.Error -> articleState.title
null -> stringResource(R.string.article_no_selected)
}
val backIcon = painterResource(R.drawable.ic_arrow_back)
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
.padding(
top = Spacing.topBarPadding,
bottom = Spacing.topBarPadding
)
.height(Spacing.topBarHeight)
) {
if (showBackButton) {
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = Spacing.iconStart)
.size(24.dp)
.clickable(onClick = onNavigateBack),
contentAlignment = Alignment.Center
) {
Icon(
painter = backIcon,
contentDescription = stringResource(R.string.action_back),
modifier = Modifier.size(24.dp)
)
}
}
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = if (showBackButton) Spacing.searchTextStart else 16.dp)
.testTag("article_title")
.semantics { testTagsAsResourceId = true }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ArticleScreen(
articleState: ArticleState?,
onNavigateBack: () -> Unit,
onRefClick: ((String) -> Unit)? = null,
onScrollPositionChanged: ((Int, Int) -> Unit)? = null,
) {
Scaffold(
topBar = {
ArticleTitleBar(
articleState = articleState,
showBackButton = true,
onNavigateBack = onNavigateBack
)
},
contentWindowInsets = AppWindowInsets.contentWithNavigation
) { padding ->
ArticleContent(
articleState = articleState,
onNavigateBack = onNavigateBack,
onRefClick = onRefClick,
onScrollPositionChanged = onScrollPositionChanged,
modifier = Modifier.padding(padding)
)
}
}
@@ -0,0 +1,46 @@
package com.example.research.ui.article
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString
/**
* A parsed DSL block with its text content and indent level.
* indent = 0 means no indent ([m1] or no m-tag),
* indent = 1 means single indent ([m2] or [m3]),
* indent = 2 means double indent ([m4] or [m5]).
*
* continuesPrevious marks a chunk produced by splitting an oversized source
* block: it belongs to the same logical paragraph as the block before it, so
* the UI renders them without inter-block spacing.
*/
@Immutable
data class DslBlock(
val text: AnnotatedString,
val indent: Int = 0,
val lazyRefs: List<DslAnnotatedParser.LazyRef> = emptyList(),
val continuesPrevious: Boolean = false,
)
/**
* Sealed class representing the state of article display.
* Success state is marked as @Immutable for Compose optimization.
*/
@Stable
sealed class ArticleState {
@Immutable
data class Loading(val title: String) : ArticleState()
@Immutable
data class Success(
val title: String,
val dslContent: List<DslBlock>,
val dictionaryName: String = "",
val dictionaryPath: String = "",
val scrollIndex: Int = 0,
val scrollOffset: Int = 0,
) : ArticleState()
@Immutable
data class Error(
val title: String,
val message: String
) : ArticleState()
}
@@ -0,0 +1,42 @@
package com.example.research.ui.article
import android.content.Context
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.font.createFontFamilyResolver
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.example.research.common.ui.theme.Typography
import com.example.research.core.performance.ReSearchTrace
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
internal const val HEAVY_ARTICLE_BLOCK_COUNT = 100
object ArticleTextWarmUp {
suspend fun warmUp(context: Context, blocks: List<DslBlock>) {
if (blocks.size <= HEAVY_ARTICLE_BLOCK_COUNT) return
val density = Density(context)
val measurer = TextMeasurer(
defaultFontFamilyResolver = createFontFamilyResolver(context),
defaultDensity = density,
defaultLayoutDirection = LayoutDirection.Ltr,
cacheSize = 0,
)
val widthPx = with(density) { WARM_UP_WIDTH.toPx().toInt() }
ReSearchTrace.asyncSection(ReSearchTrace.ARTICLE_PRE_MEASURE) {
blocks.forEach { block ->
currentCoroutineContext().ensureActive()
measurer.measure(
text = block.text,
style = Typography.bodyLarge,
constraints = Constraints(maxWidth = widthPx),
)
}
}
}
private val WARM_UP_WIDTH = 360.dp
}
@@ -0,0 +1,352 @@
package com.example.research.ui.article
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import com.example.research.core.performance.ReSearchTrace
/**
* DSL parser that keeps reference targets separate from rendered text.
*
* References are stored as lazy refs (word + char range) without creating LinkAnnotation objects.
* LinkAnnotation objects are created only when blocks become visible, reducing initial render cost.
*/
object DslAnnotatedParser {
// Pre-allocated set for O(1) tag validation
private val VALID_TAGS = setOf(
"br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5",
"ref", "ex", "e", "trn", "com", "lang", "sup", "'"
)
private val ESCAPED_CHARS = setOf('[', ']', '(', ')')
data class ColorScheme(
val secondaryText: Color,
val labelText: Color,
val linkText: Color,
)
data class LazyRef(
val word: String,
val startIndex: Int,
val endIndex: Int
)
// Cache for applyLazyRefs results to avoid recreating AnnotatedString with links
// Size: 32 blocks - covers typical visible + prefetch range in LazyColumn
// Memory estimate: ~20KB per block average (AnnotatedString with LinkAnnotations)
// Total: ~640KB worst case
// LRU eviction automatic via LruCache
private val refCache = object : androidx.collection.LruCache<Pair<DslBlock, Int>, AnnotatedString>(32) {
override fun sizeOf(key: Pair<DslBlock, Int>, value: AnnotatedString): Int = 1
}
fun parse(
dsl: String,
colorScheme: ColorScheme = defaultColorScheme(),
): List<DslBlock> = ReSearchTrace.section(ReSearchTrace.ARTICLE_PARSE) {
val result = mutableListOf<DslBlock>()
var builder = AnnotatedString.Builder()
val stack = ArrayDeque<SpanStyle>()
val refStack = ArrayDeque<Pair<Int, StringBuilder>>()
val lazyRefs = mutableListOf<LazyRef>()
var lastChar: Char? = null
var accentNext = false
var currentIndent = 0
val exampleStyle = SpanStyle(color = colorScheme.secondaryText)
val labelStyle = SpanStyle(color = colorScheme.labelText)
val linkStyle = SpanStyle(color = colorScheme.linkText, textDecoration = TextDecoration.Underline)
fun push(style: SpanStyle) {
builder.pushStyle(style)
stack.addLast(style)
}
fun pop() {
if (stack.isNotEmpty()) {
builder.pop()
stack.removeLast()
}
}
fun flushBlock() {
if (builder.length > 0) {
result.add(DslBlock(
text = builder.toAnnotatedString(),
indent = currentIndent,
lazyRefs = lazyRefs.toList()
))
builder = AnnotatedString.Builder()
stack.forEach { builder.pushStyle(it) }
lazyRefs.clear()
}
}
fun marginIndent(tagName: String): Int {
if (tagName == "m" || tagName == "m0" || tagName == "m1") return 0
val margin = tagName.drop(1).toIntOrNull() ?: return 0
return margin / 2
}
var i = 0
val length = dsl.length
while (i < length) {
when (val char = dsl[i]) {
'\\' -> {
if (i + 1 < length) {
val next = dsl[i + 1]
if (next in ESCAPED_CHARS) {
builder.append(next)
if (refStack.isNotEmpty()) {
refStack.last().second.append(next)
}
lastChar = next
i += 2
continue
}
}
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
lastChar = char
i++
}
'[' -> {
val end = dsl.indexOf(']', i + 1)
if (end != -1) {
val tagStart = i + 1
var tagEnd = end
while (tagEnd > tagStart && dsl[tagEnd - 1].isWhitespace()) tagEnd--
val fullTag = dsl.substring(tagStart, tagEnd)
val isClosing = fullTag.isNotEmpty() && fullTag[0] == '/'
val tagWithParams = if (isClosing) fullTag.substring(1) else fullTag
val spaceIdx = tagWithParams.indexOfFirst { it == ' ' || it == '\t' }
val tagName = if (spaceIdx >= 0) tagWithParams.substring(0, spaceIdx) else tagWithParams
if (tagName == "*") {
i = end + 1
continue
}
if (tagName in VALID_TAGS) {
if (isClosing) {
if (tagName == "ref" && refStack.isNotEmpty()) {
val (refStart, refText) = refStack.removeLast()
lazyRefs.add(
LazyRef(
word = refText.toString(),
startIndex = refStart,
endIndex = builder.length,
)
)
} else if (tagName == "'") {
accentNext = false
}
pop()
} else {
when (tagName) {
"'" -> {
accentNext = true
push(SpanStyle())
}
"br" -> {
if (refStack.isEmpty()) {
flushBlock()
} else {
builder.append("\n")
}
lastChar = '\n'
}
"p" -> {
push(labelStyle)
}
"b" -> push(SpanStyle(fontWeight = FontWeight.Bold))
"m", "m0", "m1", "m2", "m3", "m4", "m5" -> {
if (lastChar != '\n' && lastChar != null) {
if (refStack.isEmpty()) flushBlock() else builder.append("\n")
}
lastChar = '\n'
currentIndent = marginIndent(tagName)
push(exampleStyle)
}
"i" -> push(SpanStyle(fontStyle = FontStyle.Italic))
"ref" -> {
push(linkStyle)
refStack.addLast(builder.length to StringBuilder())
}
"ex", "e" -> push(exampleStyle)
"c", "t", "trn", "lang", "sup" -> push(SpanStyle())
"com" -> push(SpanStyle(fontStyle = FontStyle.Italic, color = colorScheme.labelText))
}
}
i = end + 1
continue
}
}
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
lastChar = char
i++
}
else -> {
builder.append(char)
if (refStack.isNotEmpty()) {
refStack.last().second.append(char)
}
if (accentNext) {
builder.append('\u0301')
if (refStack.isNotEmpty()) {
refStack.last().second.append('\u0301')
}
accentNext = false
}
lastChar = char
i++
}
}
}
if (builder.length > 0) {
result.add(DslBlock(
text = builder.toAnnotatedString(),
indent = currentIndent,
lazyRefs = lazyRefs.toList()
))
}
splitOversizedBlocks(result)
}
private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> {
if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks
return blocks.flatMap { block ->
if (block.text.length <= MAX_BLOCK_TEXT_LENGTH) listOf(block) else splitBlock(block)
}
}
private fun splitBlock(block: DslBlock): List<DslBlock> {
val text = block.text
val refs = block.lazyRefs
val cuts = mutableListOf<Int>()
var chunkStart = 0
var i = 0
while (i < text.length) {
if (text[i] == '\n' &&
i - chunkStart >= TARGET_CHUNK_TEXT_LENGTH &&
referenceAt(block, i) == null
) {
cuts.add(i)
chunkStart = i + 1
}
i++
}
if (cuts.isEmpty()) return listOf(block)
val chunks = ArrayList<DslBlock>(cuts.size + 1)
var start = 0
var refFrom = 0
fun addChunk(end: Int) {
var refTo = refFrom
while (refTo < refs.size && refs[refTo].startIndex < end) refTo++
chunks.add(
DslBlock(
text = text.subSequence(start, end),
indent = block.indent,
lazyRefs = refs.subList(refFrom, refTo).map {
it.copy(startIndex = it.startIndex - start, endIndex = it.endIndex - start)
},
continuesPrevious = start > 0,
)
)
refFrom = refTo
}
for (cut in cuts) {
addChunk(cut)
start = cut + 1
}
if (start < text.length) addChunk(text.length)
return chunks
}
fun applyLazyRefs(
block: DslBlock,
onRefClick: (String) -> Unit
): AnnotatedString {
if (block.lazyRefs.isEmpty()) return block.text
// Use block and onRefClick hashCode as cache key
val cacheKey = block to System.identityHashCode(onRefClick)
refCache[cacheKey]?.let { return it }
// Build AnnotatedString outside of cache lock
val builder = AnnotatedString.Builder(block.text)
val listener = LinkInteractionListener { link ->
val tag = (link as? LinkAnnotation.Clickable)?.tag ?: ""
if (tag.isNotEmpty()) onRefClick(tag)
}
block.lazyRefs.groupBy { it.word }.forEach { (word, refs) ->
val annotation = LinkAnnotation.Clickable(
tag = word,
linkInteractionListener = listener,
)
refs.forEach { ref ->
builder.addLink(annotation, ref.startIndex, ref.endIndex)
}
}
val result = builder.toAnnotatedString()
// LruCache handles eviction automatically
refCache.put(cacheKey, result)
return result
}
fun referenceAt(block: DslBlock, offset: Int): LazyRef? {
var low = 0
var high = block.lazyRefs.lastIndex
while (low <= high) {
val middle = (low + high).ushr(1)
val reference = block.lazyRefs[middle]
when {
offset < reference.startIndex -> high = middle - 1
offset >= reference.endIndex -> low = middle + 1
else -> return reference
}
}
return null
}
fun defaultColorScheme(): ColorScheme {
return ColorScheme(
secondaryText = Color.Unspecified,
labelText = Color(0xFF9E9E9E),
linkText = Color(0xFF1976D2)
)
}
val ARTICLE_COLOR_SCHEME = ColorScheme(
secondaryText = Color.Unspecified,
labelText = Color(0xFF4CAF50),
linkText = Color(0xFF1E88E5),
)
// Allow external cache clearing (e.g., when dictionaries change)
fun clearRefCache() {
refCache.evictAll()
}
private const val MAX_BLOCK_TEXT_LENGTH = 600
private const val TARGET_CHUNK_TEXT_LENGTH = 400
}
@@ -0,0 +1,135 @@
package com.example.research.ui.main
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.*
import com.example.research.core.domain.model.IndexEntry
import com.example.research.ui.article.ArticleContent
import com.example.research.ui.article.ArticleTitleBar
import com.example.research.ui.article.ArticleState
import com.example.research.ui.main.components.MainTopBar
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveMainScreen(
searchState: TextFieldState,
canSearch: Boolean,
isLandscape: Boolean,
results: LazyPagingItems<IndexEntry>,
errorMessage: String?,
showDictionaryName: Boolean,
articleState: ArticleState?,
scrollPosition: Pair<Int, Int>,
onAction: (MainAction) -> Unit,
onNavigateToSettings: () -> Unit,
onArticleClick: (IndexEntry) -> Unit,
onNavigateBackFromArticle: () -> Unit,
onRefClick: (String) -> Unit,
) {
var scaffoldState by remember {
mutableStateOf(
MutableThreePaneScaffoldState(
ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Hidden,
secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden,
)
)
)
}
val isQueryEmpty = searchState.text.isEmpty()
val isShowingDetail = scaffoldState.targetState.primary == PaneAdaptedValue.Expanded
val isTwoPane =
scaffoldState.targetState.primary == PaneAdaptedValue.Expanded &&
scaffoldState.targetState.secondary == PaneAdaptedValue.Expanded
val listEndPadding = if (isTwoPane) 8.dp else 16.dp
Column(modifier = Modifier.fillMaxSize()) {
MainTopBar(
searchState = searchState,
enabled = canSearch,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
onNavigateToSettings = onNavigateToSettings
)
ListDetailPaneScaffold(
directive = PaneScaffoldDirective.Default.copy(
maxHorizontalPartitions = 2,
defaultPanePreferredWidth = if (isLandscape) 260.dp else 300.dp,
),
scaffoldState = scaffoldState,
modifier = Modifier
.weight(1f)
.consumeWindowInsets(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
),
listPane = {
SearchResultsPane(
searchState = searchState,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
scrollPosition = scrollPosition,
onAction = onAction,
onArticleClick = onArticleClick,
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
)
.padding(start = 32.dp, end = listEndPadding),
listModifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
)
},
detailPane = {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 16.dp)
) {
ArticleTitleBar(
articleState = articleState,
showBackButton = isShowingDetail,
onNavigateBack = onNavigateBackFromArticle,
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
ArticleContent(
articleState = articleState,
onNavigateBack = onNavigateBackFromArticle,
onRefClick = onRefClick,
)
}
},
)
}
LaunchedEffect(articleState, isQueryEmpty) {
val targetValue = when {
articleState == null -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Hidden,
secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden,
)
isQueryEmpty -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Expanded,
secondary = PaneAdaptedValue.Hidden,
tertiary = PaneAdaptedValue.Hidden,
)
else -> ThreePaneScaffoldValue(
primary = PaneAdaptedValue.Expanded,
secondary = PaneAdaptedValue.Expanded,
tertiary = PaneAdaptedValue.Hidden,
)
}
scaffoldState.animateTo(targetValue)
}
}
@@ -0,0 +1,8 @@
package com.example.research.ui.main
sealed interface MainAction {
data class QueryChanged(val query: String) : MainAction
data object ClearQuery : MainAction
data object ClearError : MainAction
data class SaveScrollPosition(val index: Int, val offset: Int) : MainAction
}
@@ -0,0 +1,175 @@
package com.example.research.ui.main
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.research.ui.theme.AppWindowInsets
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.core.domain.model.IndexEntry
import com.example.research.feature.download.config.DictionaryConfig
import com.example.research.feature.download.model.DownloadState
import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel
import com.example.research.ui.main.components.MainTopBar
import com.example.research.ui.settings.SettingsViewModel
import com.example.research.ui.settings.ImportState
@Composable
fun MainRoute(
searchViewModel: SearchViewModel,
settingsViewModel: SettingsViewModel,
onNavigateToSettings: () -> Unit,
onArticleClick: (IndexEntry) -> Unit
) {
val results = searchViewModel.searchResults.collectAsLazyPagingItems()
val errorMessage by searchViewModel.errorMessage.collectAsStateWithLifecycle()
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
val scrollPosition = remember(searchViewModel) {
searchViewModel.scrollPosition.value
}
val searchState = remember(searchViewModel) {
TextFieldState(initialText = searchViewModel.searchQuery.value)
}
val onMainAction = remember(searchViewModel) {
{ action: MainAction ->
when (action) {
is MainAction.QueryChanged -> searchViewModel.onSearchQueryChanged(action.query)
MainAction.ClearQuery -> searchViewModel.onAction(SearchAction.ClearQuery)
MainAction.ClearError -> searchViewModel.clearError()
is MainAction.SaveScrollPosition ->
searchViewModel.saveScrollPosition(action.index, action.offset)
}
}
}
LaunchedEffect(searchState, searchQuery) {
if (searchState.text.toString() != searchQuery) {
searchState.edit { replace(0, length, searchQuery) }
}
}
val canSearch = remember(settingsState) {
val hasSearchableDictionary = settingsState.dictionaries.any { dictionary ->
dictionary.isActive && dictionary.articleCount > 0
}
settingsState.downloadState is DownloadState.Idle &&
!settingsState.indexingProgress.isIndexing &&
settingsState.importState !is ImportState.Importing &&
settingsState.importState !is ImportState.Extracting &&
hasSearchableDictionary
}
val showDictionaryName = remember(settingsState) {
val activeDictionaries = settingsState.dictionaries.filter { it.isActive }
if (activeDictionaries.isEmpty()) {
false
} else {
activeDictionaries.any { dict ->
val fileName = dict.path.substringAfterLast("/")
!DictionaryConfig.isDabkrsDictionary(fileName)
}
}
}
BoxWithConstraints {
val isWide = maxWidth >= 600.dp
val isLandscape = maxWidth > maxHeight
if (isWide) {
AdaptiveMainScreen(
searchState = searchState,
canSearch = canSearch,
isLandscape = isLandscape,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
articleState = articleState,
scrollPosition = scrollPosition,
onAction = onMainAction,
onNavigateToSettings = onNavigateToSettings,
onArticleClick = { entry ->
searchViewModel.onAction(SearchAction.ArticleClicked(entry))
},
onNavigateBackFromArticle = {
if (!searchViewModel.navigateBackFromArticle()) {
searchViewModel.onAction(SearchAction.ClearArticle)
}
},
onRefClick = { word ->
searchViewModel.onAction(SearchAction.SearchAndLoadArticle(word))
},
)
} else {
MainScreen(
searchState = searchState,
canSearch = canSearch,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
scrollPosition = scrollPosition,
onAction = onMainAction,
onArticleClick = onArticleClick,
onNavigateToSettings = onNavigateToSettings
)
}
}
}
@Composable
internal fun MainScreen(
searchState: TextFieldState,
canSearch: Boolean,
results: LazyPagingItems<IndexEntry>,
errorMessage: String?,
showDictionaryName: Boolean,
scrollPosition: Pair<Int, Int>,
onAction: (MainAction) -> Unit,
onArticleClick: (IndexEntry) -> Unit,
onNavigateToSettings: () -> Unit
) {
Scaffold(
topBar = {
MainTopBar(
searchState = searchState,
enabled = canSearch,
onQueryChange = { onAction(MainAction.QueryChanged(it)) },
onClearQuery = { onAction(MainAction.ClearQuery) },
onNavigateToSettings = onNavigateToSettings
)
},
contentWindowInsets = AppWindowInsets.navigationOnly
) { paddingValues ->
SearchResultsPane(
searchState = searchState,
results = results,
errorMessage = errorMessage,
showDictionaryName = showDictionaryName,
scrollPosition = scrollPosition,
onAction = onAction,
onArticleClick = onArticleClick,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.windowInsetsPadding(AppWindowInsets.horizontalSafeDrawing)
.padding(start = 32.dp, end = 16.dp),
)
}
}
@@ -0,0 +1,130 @@
package com.example.research.ui.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import com.example.research.core.domain.model.IndexEntry
import com.example.research.ui.main.components.ErrorMessageCard
import androidx.compose.foundation.lazy.rememberLazyListState
import com.example.research.ui.main.components.SearchResultsList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlin.time.Duration.Companion.milliseconds
@OptIn(FlowPreview::class)
@Composable
internal fun SearchResultsPane(
searchState: TextFieldState,
results: LazyPagingItems<IndexEntry>,
errorMessage: String?,
showDictionaryName: Boolean,
scrollPosition: Pair<Int, Int>,
onAction: (MainAction) -> Unit,
onArticleClick: (IndexEntry) -> Unit,
modifier: Modifier = Modifier,
listModifier: Modifier = Modifier,
) {
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = scrollPosition.first,
initialFirstVisibleItemScrollOffset = scrollPosition.second,
)
var restoredScrollPosition by remember { mutableStateOf(false) }
var pendingScrollToTop by remember { mutableStateOf(false) }
var sawRefreshForPendingScroll by remember { mutableStateOf(false) }
val imeBottomPadding = with(LocalDensity.current) {
WindowInsets.ime.getBottom(this).toDp()
}
LaunchedEffect(listState) {
snapshotFlow {
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}
.debounce(100.milliseconds)
.collect { (index, offset) ->
onAction(MainAction.SaveScrollPosition(index, offset))
}
}
LaunchedEffect(searchState, listState) {
snapshotFlow { searchState.text.toString() }
.distinctUntilChanged()
.drop(1)
.collectLatest { query ->
restoredScrollPosition = true
pendingScrollToTop = query.isNotBlank()
sawRefreshForPendingScroll = false
}
}
LaunchedEffect(results.loadState.refresh, pendingScrollToTop) {
if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) {
sawRefreshForPendingScroll = true
}
}
LaunchedEffect(
results.loadState.refresh,
pendingScrollToTop,
sawRefreshForPendingScroll,
listState,
) {
if (
pendingScrollToTop &&
sawRefreshForPendingScroll &&
results.loadState.refresh is LoadState.NotLoading
) {
snapshotFlow { listState.isScrollInProgress }.first { isScrollInProgress -> !isScrollInProgress }
listState.scrollToItem(0)
pendingScrollToTop = false
sawRefreshForPendingScroll = false
}
}
LaunchedEffect(results.loadState.refresh, restoredScrollPosition) {
if (!restoredScrollPosition && results.loadState.refresh is LoadState.NotLoading) {
if (scrollPosition.first > 0) {
listState.scrollToItem(scrollPosition.first, scrollPosition.second)
}
restoredScrollPosition = true
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
errorMessage?.let { message ->
ErrorMessageCard(
message = message,
onDismiss = { onAction(MainAction.ClearError) },
)
}
SearchResultsList(
results = results,
onArticleClick = onArticleClick,
modifier = listModifier.weight(1f),
listState = listState,
showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(bottom = imeBottomPadding),
)
}
}
@@ -0,0 +1,47 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.research.R
import androidx.compose.ui.res.stringResource
@Composable
fun ErrorMessageCard(
message: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f)
)
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_ok))
}
}
}
}
@@ -0,0 +1,179 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import com.example.research.ui.theme.AppWindowInsets
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.example.research.R
import kotlinx.coroutines.flow.distinctUntilChanged
private val TopBarHeight = 56.dp
private val TopBarShape = RoundedCornerShape(28.dp)
@Composable
fun MainTopBar(
searchState: TextFieldState,
enabled: Boolean,
onQueryChange: (String) -> Unit,
onClearQuery: () -> Unit,
onNavigateToSettings: () -> Unit
) {
val searchIcon = painterResource(R.drawable.ic_search)
val closeIcon = painterResource(R.drawable.ic_close)
val settingsIcon = painterResource(R.drawable.ic_settings)
val clearLabel = stringResource(R.string.clear_search)
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(AppWindowInsets.topAndHorizontalSafeDrawing)
.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
SearchField(
searchState = searchState,
enabled = enabled,
onQueryChange = onQueryChange,
onClearQuery = onClearQuery,
searchIcon = searchIcon,
closeIcon = closeIcon,
clearLabel = clearLabel,
modifier = Modifier.weight(1f)
)
FilledIconButton(
onClick = onNavigateToSettings,
modifier = Modifier
.size(TopBarHeight)
.testTag("settings_button")
.semantics { testTagsAsResourceId = true },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(
painter = settingsIcon,
contentDescription = stringResource(R.string.settings_title),
modifier = Modifier.size(24.dp)
)
}
}
}
}
@Composable
private fun SearchField(
searchState: TextFieldState,
enabled: Boolean,
onQueryChange: (String) -> Unit,
onClearQuery: () -> Unit,
searchIcon: androidx.compose.ui.graphics.painter.Painter,
closeIcon: androidx.compose.ui.graphics.painter.Painter,
clearLabel: String,
modifier: Modifier = Modifier
) {
val containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
val contentColor = MaterialTheme.colorScheme.onSurface
val placeholderColor = MaterialTheme.colorScheme.onSurfaceVariant
val currentOnQueryChange by rememberUpdatedState(onQueryChange)
LaunchedEffect(searchState) {
snapshotFlow { searchState.text.toString() }
.distinctUntilChanged()
.collect { query ->
currentOnQueryChange(query)
}
}
Surface(
modifier = modifier.height(TopBarHeight),
color = containerColor,
shape = TopBarShape
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = searchIcon,
contentDescription = null,
tint = placeholderColor,
modifier = Modifier.size(24.dp)
)
BasicTextField(
state = searchState,
enabled = enabled,
textStyle = MaterialTheme.typography.titleLarge.copy(color = contentColor),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
lineLimits = TextFieldLineLimits.SingleLine,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
.testTag("search_field")
.semantics { testTagsAsResourceId = true }
)
if (searchState.text.isNotEmpty()) {
IconButton(
onClick = {
searchState.edit { replace(0, length, "") }
onClearQuery()
},
modifier = Modifier
.size(40.dp)
.testTag("clear_search")
.semantics { testTagsAsResourceId = true }
) {
Icon(
painter = closeIcon,
contentDescription = clearLabel,
tint = placeholderColor,
modifier = Modifier.size(24.dp)
)
}
} else {
Box(modifier = Modifier.size(40.dp))
}
}
}
}
@@ -0,0 +1,121 @@
package com.example.research.ui.main.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.*
import com.example.research.R
import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.removeDictionarySuffixes
import com.example.research.ui.theme.Spacing
@Composable
fun SearchResultsList(
results: LazyPagingItems<IndexEntry>,
onArticleClick: (IndexEntry) -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
showDictionaryName: Boolean = true,
contentPadding: PaddingValues = PaddingValues()
) {
LazyColumn(
modifier = modifier
.testTag("search_results")
.semantics { testTagsAsResourceId = true },
state = listState,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(
count = results.itemCount,
key = results.itemKey { entry -> "${entry.dictionaryPath}_${entry.offset.value}_${entry.word}" },
contentType = results.itemContentType { "search_result" }
) { index ->
results[index]?.let { entry ->
SearchResultItem(
entry = entry,
resultIndex = index,
onArticleClick = onArticleClick,
showDictionaryName = showDictionaryName
)
}
}
when (results.loadState.append) {
is LoadState.Error -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.error_loading_more),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
}
}
else -> {}
}
}
}
@Composable
private fun SearchResultItem(
entry: IndexEntry,
resultIndex: Int,
onArticleClick: (IndexEntry) -> Unit,
showDictionaryName: Boolean = true
) {
// Cache text styles to avoid recreation on every composition
val titleStyle = MaterialTheme.typography.titleLarge
val subtitleStyle = MaterialTheme.typography.bodySmall
val subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant
Column(
modifier = Modifier
.fillMaxWidth()
.testTag("search_result")
.semantics { testTagsAsResourceId = true }
.clickable { onArticleClick(entry) }
.padding(vertical = Spacing.listItemVertical)
) {
Text(
text = entry.originalWord,
modifier = Modifier
.testTag("search_result_row_$resultIndex")
.semantics { testTagsAsResourceId = true },
style = titleStyle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (showDictionaryName) {
// Cache dictionary name to avoid repeated suffix removal
val dictionaryDisplayName = remember(entry.dictionaryName) {
entry.dictionaryName.removeDictionarySuffixes()
}
Text(
text = dictionaryDisplayName,
style = subtitleStyle,
color = subtitleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@@ -0,0 +1,173 @@
package com.example.research.ui.navigation
import androidx.activity.compose.BackHandler
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.*
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
import com.example.research.feature.search.SearchAction
import com.example.research.feature.search.SearchViewModel
import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.about.AboutScreen
import com.example.research.ui.article.ArticleRoute
import com.example.research.ui.main.MainRoute
import com.example.research.ui.settings.ImportState
import com.example.research.ui.settings.SettingsRoute
import com.example.research.ui.settings.SettingsViewModel
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
private data class NavigationState(
val shouldShowSettings: Boolean,
val showBackButtonInSettings: Boolean,
val isOperationActive: Boolean,
)
enum class Screen {
Home, Settings, Article, About
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AppNavigation(
searchViewModel: SearchViewModel,
settingsViewModel: SettingsViewModel
) {
val screenStack = rememberSaveable(
saver = listSaver(
save = { stateList -> stateList.map { it.name } },
restore = { names ->
val restored = names.mapNotNull { name ->
runCatching { Screen.valueOf(name) }.getOrNull()
}
mutableStateListOf<Screen>().apply {
if (restored.isNotEmpty()) addAll(restored) else add(Screen.Home)
}
}
)
) {
mutableStateListOf(Screen.Home)
}
val currentScreen = screenStack.lastOrNull() ?: Screen.Home
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val progressSnapshot by settingsViewModel.progressSnapshot.collectAsStateWithLifecycle()
val isPipelineActiveByState = remember(
progressSnapshot,
settingsState.indexingProgress.isIndexing,
settingsState.downloadState,
settingsState.importState,
) {
progressSnapshot != null ||
settingsState.indexingProgress.isIndexing ||
settingsState.downloadState is DownloadState.Loading ||
settingsState.downloadState is DownloadState.Extracting ||
settingsState.downloadState is DownloadState.Success ||
settingsState.importState is ImportState.Importing ||
settingsState.importState is ImportState.Extracting ||
settingsState.importState is ImportState.Success
}
var stickyPipelineActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(isPipelineActiveByState) {
if (isPipelineActiveByState) {
stickyPipelineActive = true
} else {
delay(1500.milliseconds)
stickyPipelineActive = false
}
}
val navState by remember {
derivedStateOf {
val isOperationActive = stickyPipelineActive
val hasConfirmedNoDictionaries =
settingsState.hasCompletedStartupScan && settingsState.dictionaries.isEmpty()
NavigationState(
shouldShowSettings = hasConfirmedNoDictionaries || isOperationActive,
showBackButtonInSettings = settingsState.dictionaries.isNotEmpty() && !isOperationActive,
isOperationActive = isOperationActive,
)
}
}
val shouldShowSettings = navState.shouldShowSettings
val showBackButtonInSettings = navState.showBackButtonInSettings
val isOperationActive = navState.isOperationActive
val isWideScreen = currentWindowAdaptiveInfo()
.windowSizeClass
.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
BackHandler(enabled = screenStack.size > 1 && !(currentScreen == Screen.Settings && isOperationActive)) {
if (currentScreen == Screen.Article) {
if (searchViewModel.navigateBackFromArticle()) {
return@BackHandler
}
searchViewModel.onAction(SearchAction.ClearArticle)
}
if (screenStack.isNotEmpty()) {
screenStack.removeAt(screenStack.lastIndex)
}
}
val articleState by searchViewModel.selectedArticle.collectAsStateWithLifecycle()
val effectiveScreen = when {
currentScreen == Screen.About -> Screen.About
currentScreen == Screen.Article && articleState == null -> if (shouldShowSettings) Screen.Settings else Screen.Home
shouldShowSettings -> Screen.Settings
else -> currentScreen
}
when (effectiveScreen) {
Screen.Home -> {
MainRoute(
searchViewModel = searchViewModel,
settingsViewModel = settingsViewModel,
onNavigateToSettings = { screenStack.add(Screen.Settings) },
onArticleClick = { entry ->
searchViewModel.onAction(SearchAction.ArticleClicked(entry))
if (!isWideScreen) {
screenStack.add(Screen.Article)
}
}
)
}
Screen.Settings -> {
SettingsRoute(
viewModel = settingsViewModel,
showBackButton = showBackButtonInSettings,
onNavigateBack = {
if (!isOperationActive && !shouldShowSettings) {
if (screenStack.size > 1) {
screenStack.removeAt(screenStack.lastIndex)
} else {
screenStack.clear()
screenStack.add(Screen.Home)
}
}
},
onNavigateToAbout = { screenStack.add(Screen.About) }
)
}
Screen.Article -> {
ArticleRoute(
searchViewModel = searchViewModel,
onNavigateBack = {
if (searchViewModel.navigateBackFromArticle()) {
return@ArticleRoute
}
searchViewModel.onAction(SearchAction.ClearArticle)
if (screenStack.size > 1) screenStack.removeAt(screenStack.lastIndex)
}
)
}
Screen.About -> {
AboutScreen(
onNavigateBack = {
if (screenStack.size > 1) screenStack.removeAt(screenStack.lastIndex)
}
)
}
}
}
@@ -0,0 +1,9 @@
package com.example.research.ui.settings
sealed interface ImportState {
data object Idle : ImportState
data class Importing(val progress: Float) : ImportState
data class Extracting(val progress: Float) : ImportState
data object Success : ImportState
data class Error(val message: String) : ImportState
}
@@ -0,0 +1,281 @@
package com.example.research.ui.settings
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.example.research.R
import com.example.research.common.ui.appTestTag
import com.example.research.common.ui.components.CollapsibleHeader
import com.example.research.common.ui.components.SectionCard
import com.example.research.ui.settings.components.DictionaryManagement
import com.example.research.ui.settings.components.LanguageSelectionSection
import com.example.research.ui.settings.components.ThemeSelectionSection
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsRoute(
viewModel: SettingsViewModel,
onNavigateBack: () -> Unit,
showBackButton: Boolean = true,
onNavigateToAbout: () -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
val snackbarHostState = remember { SnackbarHostState() }
val filePickerMutex = remember { Mutex(locked = false) }
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uris: List<Uri> ->
if (filePickerMutex.isLocked) {
filePickerMutex.unlock()
}
if (uris.isNotEmpty()) {
viewModel.onEvent(SettingsUiEvent.ImportDictionaries(uris))
}
}
LaunchedEffect(viewModel, lifecycleOwner, snackbarHostState) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.effect.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
}
SettingsScreen(
uiState = uiState,
progressSnapshotFlow = viewModel.progressSnapshot,
snackbarHostState = snackbarHostState,
showBackButton = showBackButton,
onNavigateBack = onNavigateBack,
onEvent = viewModel::onEvent,
onImportClick = {
if (filePickerMutex.tryLock()) {
filePickerLauncher.launch(arrayOf("application/octet-stream", "*/*"))
}
},
onNavigateToAbout = onNavigateToAbout
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
uiState: SettingsUiState,
progressSnapshotFlow: StateFlow<com.example.research.common.progress.DictionaryProgressModel.Snapshot?>,
snackbarHostState: SnackbarHostState,
showBackButton: Boolean,
onNavigateBack: () -> Unit,
onEvent: (SettingsUiEvent) -> Unit,
onImportClick: () -> Unit,
onNavigateToAbout: () -> Unit
) {
val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings_title)) },
navigationIcon = {
if (showBackButton) {
IconButton(onClick = onNavigateBack) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back),
contentDescription = stringResource(R.string.action_back)
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.appTestTag("settings_screen")
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item(key = "about", contentType = "about") {
SectionCard(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.testTag("about_card")
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNavigateToAbout()
}
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_app_info),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Text(
text = "ReSearch",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
item(key = "dictionaries", contentType = "dictionaries") {
DictionaryManagement(
dictionaries = uiState.dictionaries,
sources = uiState.dictionarySources,
downloadState = uiState.downloadState,
dictionaryStatus = uiState.dictionaryStatus,
importState = uiState.importState,
isIndexing = uiState.indexingProgress.isIndexing,
isExpanded = uiState.isDictionariesExpanded,
progressSnapshotFlow = progressSnapshotFlow,
onToggleExpanded = { onEvent(SettingsUiEvent.SectionToggled("dictionaries", !uiState.isDictionariesExpanded)) },
onAddSources = { onEvent(SettingsUiEvent.AddDictionarySources(it)) },
onStartDownload = { onEvent(SettingsUiEvent.StartDownload) },
onCancelDownload = { onEvent(SettingsUiEvent.CancelDownload) },
onToggleDictionary = { onEvent(SettingsUiEvent.DictionaryToggled(it)) },
onDeleteDictionary = { onEvent(SettingsUiEvent.DictionaryDeleted(it)) },
onImportClick = onImportClick,
onShowError = { msg ->
scope.launch {
snackbarHostState.showSnackbar(msg)
}
}
)
}
item(key = "theme", contentType = "settings-section") {
CollapsibleSettingsSection(
title = stringResource(R.string.theme),
iconRes = R.drawable.ic_palette,
testTag = "theme_section",
isExpanded = uiState.isThemeExpanded,
onToggleExpanded = { onEvent(SettingsUiEvent.SectionToggled("theme", !uiState.isThemeExpanded)) },
) {
ThemeSelectionSection(
currentTheme = uiState.theme,
onThemeSelected = { theme ->
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onEvent(SettingsUiEvent.ThemeChanged(theme))
}
)
}
}
item(key = "language", contentType = "settings-section") {
CollapsibleSettingsSection(
title = stringResource(R.string.language),
iconRes = R.drawable.ic_language,
testTag = "language_section",
isExpanded = uiState.isLanguageExpanded,
onToggleExpanded = { onEvent(SettingsUiEvent.SectionToggled("language", !uiState.isLanguageExpanded)) },
) {
LanguageSelectionSection(
currentLanguage = uiState.language,
onLanguageChanged = { lang ->
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onEvent(SettingsUiEvent.LanguageChanged(lang))
}
)
}
}
item(key = "bottom-spacer", contentType = "spacer") {
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
@Composable
private fun CollapsibleSettingsSection(
title: String,
iconRes: Int,
testTag: String,
isExpanded: Boolean,
onToggleExpanded: () -> Unit,
content: @Composable () -> Unit
) {
SectionCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
CollapsibleHeader(
title = title,
iconRes = iconRes,
isExpanded = isExpanded,
onToggle = onToggleExpanded,
testTag = testTag
)
if (isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
content()
}
}
}
}
}
@@ -0,0 +1,38 @@
package com.example.research.ui.settings
import android.net.Uri
import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress
import com.example.research.DictionaryStatus
import com.example.research.feature.download.model.DownloadState
data class SettingsUiState(
val theme: AppTheme = AppTheme.SYSTEM,
val language: String = "system",
val isThemeExpanded: Boolean = false,
val isLanguageExpanded: Boolean = false,
val isDictionariesExpanded: Boolean = true,
val dictionaries: List<Dictionary> = emptyList(),
val dictionaryStatus: DictionaryStatus = DictionaryStatus.Unknown,
val indexingProgress: IndexingProgress = IndexingProgress(),
val downloadState: DownloadState = DownloadState.Idle,
val importState: ImportState = ImportState.Idle,
val dictionarySources: List<DictionarySource> = emptyList(),
val hasCompletedStartupScan: Boolean = false,
val appVersion: String = "1.0",
)
sealed interface SettingsUiEvent {
data class ThemeChanged(val theme: AppTheme) : SettingsUiEvent
data class LanguageChanged(val language: String) : SettingsUiEvent
data class SectionToggled(val key: String, val isExpanded: Boolean) : SettingsUiEvent
data class DictionaryToggled(val path: String) : SettingsUiEvent
data class DictionaryDeleted(val dictionary: Dictionary) : SettingsUiEvent
data class ImportDictionaries(val uris: List<Uri>) : SettingsUiEvent
data object StartDownload : SettingsUiEvent
data object CancelDownload : SettingsUiEvent
data class AddDictionarySources(val urlTemplates: List<String>) : SettingsUiEvent
}
@@ -0,0 +1,553 @@
package com.example.research.ui.settings
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.research.DictionaryStatus
import com.example.research.R
import com.example.research.common.progress.DictionaryProgressStateHolder
import com.example.research.core.domain.model.AppTheme
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.IndexingProgress
import com.example.research.core.domain.usecase.DictionarySourceValidator
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
import com.example.research.core.util.OperationResult
import com.example.research.data.local.preferences.PreferencesManager
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager
import com.example.research.feature.download.model.DownloadState
import com.example.research.feature.download.repository.DictionaryRepository
import com.example.research.feature.import.DictionaryImportManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private data class DictionaryStateInputs(
val theme: AppTheme,
val dictionarySources: List<DictionarySource>,
val dictionaries: List<Dictionary>,
val indexingProgress: IndexingProgress,
)
private data class OperationStateInputs(
val downloadState: DownloadState,
val language: String,
val importState: ImportState,
val dictionaryStatus: DictionaryStatus,
val hasCompletedStartupScan: Boolean,
val isThemeExpanded: Boolean,
val isLanguageExpanded: Boolean,
val isDictionariesExpanded: Boolean,
)
class SettingsViewModel(
application: Application,
private val preferencesManager: PreferencesManager,
private val dictionaryRepository: DictionaryRepository,
private val downloadManager: DownloadManager,
private val localDictionaryRepository: LocalDictionaryRepository,
private val dictionaryImportManager: DictionaryImportManager,
progressStateHolder: DictionaryProgressStateHolder,
appVersion: String,
) : AndroidViewModel(application) {
private val manageDictionarySourcesUseCase = ManageDictionarySourcesUseCase(
preferencesManager = preferencesManager,
validator = DictionarySourceValidator(),
)
private val _uiState = MutableStateFlow(SettingsUiState(appVersion = appVersion))
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
val progressSnapshot = progressStateHolder.progressSnapshot
private val effectChannel =
kotlinx.coroutines.channels.Channel<String>(kotlinx.coroutines.channels.Channel.BUFFERED)
val effect = effectChannel.receiveAsFlow()
private val dictionaryStatus = MutableStateFlow<DictionaryStatus>(DictionaryStatus.Unknown)
private val language = MutableStateFlow("system")
private val hasCompletedStartupScan = MutableStateFlow(false)
private val pendingSourceUrls = mutableSetOf<String>()
private var isDownloadInProgress = false
init {
setupStateObservation()
initialize()
}
private fun setupStateObservation() {
viewModelScope.launch {
var wasIndexing = false
val dictionaryStateInputs = combine(
preferencesManager.themeMode.map { mode ->
try { AppTheme.valueOf(mode) } catch (_: Exception) { AppTheme.SYSTEM }
},
preferencesManager.dictionarySources,
localDictionaryRepository.dictionaries,
localDictionaryRepository.indexingProgress,
) { theme, dictionarySources, dictionaries, indexingProgress ->
DictionaryStateInputs(
theme = theme,
dictionarySources = dictionarySources,
dictionaries = dictionaries,
indexingProgress = indexingProgress,
)
}
val operationStateInputs = combine(
downloadManager.downloadState,
language,
dictionaryImportManager.importState,
dictionaryStatus,
hasCompletedStartupScan,
preferencesManager.isThemeExpanded,
preferencesManager.isLanguageExpanded,
preferencesManager.isDictionariesExpanded,
) { params: Array<Any> ->
@Suppress("UNCHECKED_CAST")
OperationStateInputs(
downloadState = params[0] as DownloadState,
language = params[1] as String,
importState = params[2] as ImportState,
dictionaryStatus = params[3] as DictionaryStatus,
hasCompletedStartupScan = params[4] as Boolean,
isThemeExpanded = params[5] as Boolean,
isLanguageExpanded = params[6] as Boolean,
isDictionariesExpanded = params[7] as Boolean,
)
}
combine(dictionaryStateInputs, operationStateInputs) { dictionaryState, operationState ->
// Handle indexing completion side effect
val isIndexing = dictionaryState.indexingProgress.isIndexing
if (wasIndexing && !isIndexing) {
if (isDownloadInProgress) {
isDownloadInProgress = false
dictionaryStatus.value = DictionaryStatus.UpToDate
}
}
wasIndexing = isIndexing
when (operationState.downloadState) {
is DownloadState.Loading, is DownloadState.Extracting -> {
isDownloadInProgress = true
}
is DownloadState.Success -> {
pendingSourceUrls.clear()
}
is DownloadState.Error -> {
isDownloadInProgress = false
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty
} else {
DictionaryStatus.UpToDate
}
if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate ->
val source = dictionaryState.dictionarySources.find {
it.urlTemplate == urlTemplate
}
if (source != null) {
val hasDictionary = dictionaryState.dictionaries.any { dict ->
DictionarySource.matchesDictionaryFile(
urlTemplate,
File(dict.path).name
)
}
if (!hasDictionary) {
manageDictionarySourcesUseCase.removeSource(source.id)
}
}
}
pendingSourceUrls.clear()
}
}
is DownloadState.Cancelled -> {
isDownloadInProgress = false
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty
} else {
DictionaryStatus.UpToDate
}
if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate ->
val source = dictionaryState.dictionarySources.find {
it.urlTemplate == urlTemplate
}
source?.let { manageDictionarySourcesUseCase.removeSource(it.id) }
}
pendingSourceUrls.clear()
}
}
else -> {}
}
SettingsUiState(
theme = dictionaryState.theme,
dictionarySources = dictionaryState.dictionarySources,
dictionaries = dictionaryState.dictionaries,
indexingProgress = IndexingProgress(isIndexing = isIndexing),
downloadState = operationState.downloadState,
language = operationState.language,
importState = operationState.importState,
dictionaryStatus = operationState.dictionaryStatus,
hasCompletedStartupScan = operationState.hasCompletedStartupScan,
isThemeExpanded = operationState.isThemeExpanded,
isLanguageExpanded = operationState.isLanguageExpanded,
isDictionariesExpanded = operationState.isDictionariesExpanded,
appVersion = _uiState.value.appVersion,
)
}.collect { newState ->
_uiState.value = newState
}
}
}
private fun initialize() {
viewModelScope.launch {
try {
scanDirectoryInternal(preferencesManager.dictionaryPath)
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "Initial scan failed", e)
dictionaryStatus.value = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
DictionaryStatus.Error(
e.message ?: getApplication<Application>().getString(R.string.error_scan_directory_failed)
)
} else {
DictionaryStatus.UpToDate
}
} finally {
try {
language.value = preferencesManager.language.first()
} catch (e: Exception) {
android.util.Log.w("SettingsViewModel", "Failed to load language preference: ${e.message}")
}
hasCompletedStartupScan.value = true
}
}
}
fun onEvent(event: SettingsUiEvent) {
when (event) {
is SettingsUiEvent.ThemeChanged -> setTheme(event.theme)
is SettingsUiEvent.LanguageChanged -> saveLanguage(event.language)
is SettingsUiEvent.SectionToggled -> saveSectionExpanded(event.key, event.isExpanded)
is SettingsUiEvent.DictionaryToggled -> toggleDictionaryActive(event.path)
is SettingsUiEvent.DictionaryDeleted -> deleteDictionary(event.dictionary)
is SettingsUiEvent.ImportDictionaries -> importDictionaries(event.uris)
SettingsUiEvent.StartDownload -> startDownload()
SettingsUiEvent.CancelDownload -> cancelDownload()
is SettingsUiEvent.AddDictionarySources -> addDictionarySources(event.urlTemplates)
}
}
private suspend fun evaluateDictionaryStatus(): DictionaryStatus {
return try {
val actualDictionaries = localDictionaryRepository.dictionaries.first()
val hasDictionaries = actualDictionaries.isNotEmpty()
if (!hasDictionaries) {
return DictionaryStatus.Empty
}
var sources = preferencesManager.dictionarySources.first()
if (downloadManager.downloadState.value !is DownloadState.Loading &&
downloadManager.downloadState.value !is DownloadState.Extracting
) {
val installedSources = installedSources(sources, actualDictionaries)
val installedSourceIds = installedSources.mapTo(mutableSetOf(), DictionarySource::id)
val staleSourceIds = sources
.filterNot { it.id in installedSourceIds }
.map(DictionarySource::id)
if (staleSourceIds.isNotEmpty()) {
preferencesManager.removeDictionarySources(staleSourceIds)
sources = installedSources
}
}
val enabledSources = installedEnabledSources(sources, actualDictionaries)
if (enabledSources.isEmpty()) {
return DictionaryStatus.UpToDate
}
val upToDate = dictionaryRepository.areDictionariesUpToDate(enabledSources)
return if (upToDate) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e)
if (localDictionaryRepository.dictionaries.value.isNotEmpty()) {
return DictionaryStatus.UpToDate
}
DictionaryStatus.Error(e.message ?: getApplication<Application>().getString(R.string.error_checking_dictionaries))
}
}
private fun setTheme(theme: AppTheme) {
viewModelScope.launch {
preferencesManager.saveThemeMode(theme.name)
}
}
private fun saveSectionExpanded(key: String, isExpanded: Boolean) {
viewModelScope.launch {
preferencesManager.saveSectionExpanded(key, isExpanded)
}
}
private fun saveLanguage(languageCode: String) {
viewModelScope.launch {
preferencesManager.saveLanguage(languageCode)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
val locales = if (languageCode == "system") {
android.os.LocaleList.getEmptyLocaleList()
} else {
android.os.LocaleList.forLanguageTags(languageCode)
}
getApplication<Application>()
.getSystemService(android.app.LocaleManager::class.java)
?.applicationLocales = locales
}
language.value = languageCode
}
}
private fun toggleDictionaryActive(dictionaryPath: String) {
localDictionaryRepository.toggleDictionaryActive(dictionaryPath)
}
private fun deleteDictionary(dictionary: Dictionary) {
viewModelScope.launch {
localDictionaryRepository.deleteDictionary(dictionary)
manageDictionarySourcesUseCase.removeSourceForDictionary(dictionary)
val remainingDictionaries = localDictionaryRepository.dictionaries.first()
if (remainingDictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty
}
}
}
private fun addDictionarySources(urlTemplates: List<String>) {
viewModelScope.launch {
try {
val validUrls = mutableListOf<String>()
urlTemplates.forEach { urlTemplate ->
val trimmed = urlTemplate.trim()
if (trimmed.isEmpty() || trimmed.length > 2048) {
return@forEach
}
when (manageDictionarySourcesUseCase.addSource(trimmed)) {
is ManageDictionarySourcesUseCase.AddSourceResult.Success -> {
pendingSourceUrls.add(DictionarySource.normalizeTemplate(trimmed))
validUrls.add(trimmed)
}
is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> {
// Validation failed, skip this source
}
}
}
if (validUrls.isNotEmpty()) {
startDownloadForSources(validUrls)
}
} catch (e: Exception) {
android.util.Log.e("SettingsViewModel", "Error adding dictionary sources", e)
}
}
}
private fun startDownloadForSources(sourceUrls: List<String>) {
viewModelScope.launch {
if (localDictionaryRepository.isIndexingInProgress()) {
effectChannel.send(
getApplication<Application>().getString(R.string.indexing_already_in_progress)
)
return@launch
}
dictionaryStatus.value = DictionaryStatus.Checking
isDownloadInProgress = true
val intent = android.content.Intent(
getApplication(),
com.example.research.feature.download.service.DictionaryForegroundService::class.java
).apply {
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_START
putStringArrayListExtra("source_urls", ArrayList(sourceUrls))
}
getApplication<Application>().startForegroundService(intent)
}
}
private fun startDownload() {
viewModelScope.launch {
if (localDictionaryRepository.isIndexingInProgress()) {
effectChannel.send(
getApplication<Application>().getString(R.string.indexing_already_in_progress)
)
return@launch
}
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
if (status !is DictionaryStatus.NeedsUpdate) return@launch
val installedSources = installedEnabledSources(
preferencesManager.dictionarySources.first(),
localDictionaryRepository.dictionaries.first()
)
if (installedSources.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.UpToDate
return@launch
}
isDownloadInProgress = true
val intent = android.content.Intent(
getApplication(),
com.example.research.feature.download.service.DictionaryForegroundService::class.java
).apply {
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_START
putStringArrayListExtra(
"source_urls",
ArrayList(installedSources.map(DictionarySource::urlTemplate))
)
}
getApplication<Application>().startForegroundService(intent)
}
}
private fun installedEnabledSources(
sources: List<DictionarySource>,
dictionaries: List<Dictionary>
): List<DictionarySource> = installedSources(sources, dictionaries)
.filter(DictionarySource::isEnabled)
private fun installedSources(
sources: List<DictionarySource>,
dictionaries: List<Dictionary>
): List<DictionarySource> {
val installedFileNames = dictionaries.map { File(it.path).name }
return sources.filter { source ->
installedFileNames.any { fileName ->
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
}
}
}
private fun cancelDownload() {
// Cancel the ViewModel-side scan job so no further status recomputes
localDictionaryRepository.cancelIndexing()
// Delegate the actual cancellation to the foreground service via
// ACTION_STOP. The service cancels its coroutine scope *before*
// telling DownloadManager / DictionaryImportManager / the repository
// to clear their state — that ordering is what prevents an in-flight
// extract/index callback from racing the cancel and republishing
// a stale progress value (which used to leave the bar stuck until
val stopIntent = android.content.Intent(
getApplication(),
com.example.research.feature.download.service.DictionaryForegroundService::class.java
).apply {
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_STOP
}
try {
getApplication<Application>().startService(stopIntent)
} catch (e: Exception) {
// If the service can't be started for any reason (e.g. background
// restrictions), fall back to local cancellation so the UI still
android.util.Log.w("SettingsViewModel", "Failed to start stop service: ${e.message}")
dictionaryImportManager.cancelImport()
downloadManager.cancelDownload()
getApplication<Application>().stopService(stopIntent)
}
viewModelScope.launch {
val dictionaries = localDictionaryRepository.dictionaries.first()
dictionaryStatus.value = if (dictionaries.isNotEmpty()) {
DictionaryStatus.UpToDate
} else {
DictionaryStatus.Empty
}
}
}
private suspend fun scanDirectoryInternal(path: String): OperationResult<Int> {
val result = localDictionaryRepository.scanDirectory(path)
when (result) {
is OperationResult.Success -> {
val dictionaries = localDictionaryRepository.dictionaries.first()
if (dictionaries.isEmpty()) {
dictionaryStatus.value = DictionaryStatus.Empty
isDownloadInProgress = false
} else {
if (isDownloadInProgress) {
dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false
} else {
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
}
}
if (result.data > 0) {
viewModelScope.launch(Dispatchers.Default) {
localDictionaryRepository.warmupIndexes()
}
viewModelScope.launch(Dispatchers.IO) {
localDictionaryRepository.loadMetadataAsync()
}
}
}
else -> {
isDownloadInProgress = false
}
}
return result
}
private fun importDictionaries(uris: List<Uri>) {
viewModelScope.launch {
if (localDictionaryRepository.isIndexingInProgress()) {
effectChannel.send(
getApplication<Application>().getString(R.string.indexing_already_in_progress)
)
return@launch
}
isDownloadInProgress = true
val intent = android.content.Intent(
getApplication(),
com.example.research.feature.download.service.DictionaryForegroundService::class.java
).apply {
action = com.example.research.feature.download.service.DictionaryForegroundService.ACTION_IMPORT
}
getApplication<Application>().startForegroundService(intent)
dictionaryImportManager.importDictionaries(uris)
}
}
}
@@ -0,0 +1,45 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.research.R
@Composable
fun DictionaryActionButtons(
onAddSource: () -> Unit,
onImportClick: () -> Unit,
isImportEnabled: Boolean,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally)
) {
TextButton(
onClick = onAddSource,
modifier = Modifier.testTag("add_source_button"),
) {
Text(
text = stringResource(R.string.dictionary_source_add_button),
style = MaterialTheme.typography.labelLarge,
maxLines = 1
)
}
TextButton(
onClick = onImportClick,
enabled = isImportEnabled
) {
Text(
text = stringResource(R.string.import_dictionary_button),
style = MaterialTheme.typography.labelLarge,
maxLines = 1
)
}
}
}
@@ -0,0 +1,261 @@
package com.example.research.ui.settings.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight
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.core.domain.model.Dictionary
import kotlinx.coroutines.launch
private val ItemHorizontalPadding: Dp = 12.dp
private val ItemContentSpacing: Dp = 12.dp
private val SwipeSettledOffset: Dp = 48.dp
private val DictionaryItemHeight: Dp = 64.dp
private val SwipeThreshold: Dp = 40.dp
private val IconSize: Dp = 24.dp
private val IconButtonSize: Dp = 48.dp
private val DeleteIconColor = Color(0xFFB3261E)
private const val SwitchScale: Float = 0.8f
@Stable
private enum class DictionaryFileType(val label: String) {
DictZip("DictZip"),
Dsl("DSL"),
Other("File");
companion object {
fun fromPath(path: String): DictionaryFileType = when {
path.endsWith(".dsl.dz") -> DictZip
path.endsWith(".dsl") -> Dsl
else -> Other
}
}
}
@Composable
fun DictionaryListItem(
dictionary: Dictionary,
onToggle: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
val haptic = LocalHapticFeedback.current
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val maxSwipePx = remember(density) { with(density) { SwipeSettledOffset.toPx() } }
val swipeThresholdPx = remember(density) { with(density) { SwipeThreshold.toPx() } }
val offsetAnim = remember { Animatable(0f) }
var rawOffset by remember { mutableFloatStateOf(0f) }
val isDeleteRevealed by remember { derivedStateOf { rawOffset <= -swipeThresholdPx } }
LaunchedEffect(isDeleteRevealed) {
if (isDeleteRevealed) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
val currentOnDelete by rememberUpdatedState(onDelete)
val currentOnToggle by rememberUpdatedState(onToggle)
val fileType = remember(dictionary.path) { DictionaryFileType.fromPath(dictionary.path) }
val displayName = remember(dictionary.metadata.name, dictionary.name) {
dictionary.metadata.name.ifBlank { dictionary.name }
}
val switchTestTag = remember(dictionary.path) {
val fileName = dictionary.path.substringAfterLast('/')
val dictionaryId = fileName
.removeSuffix(".dsl.dz")
.removeSuffix(".dsl")
.removeSuffix(".gz")
.replace(Regex("[^A-Za-z0-9_]"), "_")
"dictionary_switch_$dictionaryId"
}
Box(
modifier = modifier
.fillMaxWidth()
.testTag("dictionary_item")
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(DictionaryItemHeight)
.background(MaterialTheme.colorScheme.surfaceVariant)
.graphicsLayer { translationX = offsetAnim.value }
.pointerInput(maxSwipePx, swipeThresholdPx) {
detectHorizontalDragGestures(
onDragStart = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
},
onDragEnd = {
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
rawOffset = target
scope.launch {
offsetAnim.animateTo(
target,
animationSpec = spring(stiffness = Spring.StiffnessMedium)
)
}
},
onDragCancel = {
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
rawOffset = target
scope.launch {
offsetAnim.animateTo(
target,
animationSpec = spring(stiffness = Spring.StiffnessMedium)
)
}
},
onHorizontalDrag = { _, dragAmount ->
val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f)
rawOffset = newOffset
scope.launch { offsetAnim.snapTo(newOffset) }
}
)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ItemContentSpacing)
) {
Box(
modifier = Modifier.size(IconButtonSize),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(R.drawable.ic_book),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(IconSize)
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = displayName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = fileType.label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1
)
if (dictionary.articleCount > 0) {
Text(
text = pluralStringResource(
R.plurals.dictionary_entries_count,
dictionary.articleCount,
dictionary.articleCount
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
} else {
Text(
text = stringResource(R.string.dictionary_not_indexed),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
if (isDeleteRevealed) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
currentOnDelete()
scope.launch { offsetAnim.snapTo(0f) }
rawOffset = 0f
},
modifier = Modifier
.padding(end = ItemHorizontalPadding)
.graphicsLayer { translationX = -offsetAnim.value }
.size(IconButtonSize)
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = stringResource(R.string.dictionary_delete),
tint = DeleteIconColor,
modifier = Modifier.size(IconSize)
)
}
} else {
Switch(
checked = dictionary.isActive,
onCheckedChange = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
currentOnToggle()
},
enabled = dictionary.articleCount > 0,
modifier = Modifier
.padding(end = ItemHorizontalPadding)
.scale(SwitchScale)
.testTag(switchTestTag)
.semantics { testTagsAsResourceId = true }
)
}
}
}
}
@@ -0,0 +1,263 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.research.DictionaryStatus
import com.example.research.R
import com.example.research.common.progress.DictionaryProgressModel
import com.example.research.common.progress.renderTitle
import com.example.research.common.ui.components.CollapsibleHeader
import com.example.research.common.ui.components.EmptyState
import com.example.research.common.ui.components.SectionCard
import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.DictionarySource
import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.settings.ImportState
import kotlinx.coroutines.flow.StateFlow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DictionaryManagement(
dictionaries: List<Dictionary>,
sources: List<DictionarySource>,
downloadState: DownloadState,
dictionaryStatus: DictionaryStatus,
importState: ImportState,
isIndexing: Boolean,
isExpanded: Boolean,
progressSnapshotFlow: StateFlow<DictionaryProgressModel.Snapshot?>,
onToggleExpanded: () -> Unit,
onAddSources: (List<String>) -> Unit,
onStartDownload: () -> Unit,
onCancelDownload: () -> Unit,
onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit,
onImportClick: () -> Unit,
onShowError: (String) -> Unit,
modifier: Modifier = Modifier
) {
val (showUrlBottomSheet, setShowUrlBottomSheet) = remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val haptic = LocalHapticFeedback.current
val focusManager = LocalFocusManager.current
val isInProgress = isIndexing ||
downloadState is DownloadState.Loading ||
downloadState is DownloadState.Extracting ||
downloadState is DownloadState.Success ||
importState is ImportState.Importing ||
importState is ImportState.Extracting ||
importState is ImportState.Success
val isImporting by remember(importState) {
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
}
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
SectionCard(
modifier = modifier
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
val headerSubtitle = if (!isExpanded && dictionaries.isNotEmpty()) {
pluralStringResource(
R.plurals.active_dictionaries_count,
dictionaries.count { it.isActive },
dictionaries.count { it.isActive }
)
} else null
CollapsibleHeader(
title = stringResource(R.string.dictionary_management_title),
iconRes = R.drawable.ic_dictionary_management,
isExpanded = isExpanded,
onToggle = onToggleExpanded,
subtitle = headerSubtitle,
testTag = "dictionary_header"
)
if (isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
DictionaryProgressSectionHost(
visible = isInProgress,
progressSnapshotFlow = progressSnapshotFlow,
onCancel = onCancelDownload,
)
LaunchedEffect(downloadState, dictionaryStatus, importState) {
val errorMessage = when {
downloadState is DownloadState.Error -> downloadState.message
dictionaryStatus is DictionaryStatus.Error -> dictionaryStatus.message
importState is ImportState.Error -> importState.message
else -> null
}
errorMessage?.let { onShowError(it) }
}
// Disable accessibility on background content during import to improve performance.
// The progress dialog remains accessible for cancellation.
val backgroundModifier = if (isInProgress) {
Modifier.semantics(mergeDescendants = true) { }
} else {
Modifier
}
if (dictionaries.isEmpty() && !isInProgress) {
Column(
modifier = backgroundModifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
EmptyState(
message = stringResource(R.string.dictionaries_not_downloaded),
description = stringResource(R.string.dictionaries_empty_description)
)
DictionaryActionButtons(
onAddSource = {
setShowUrlBottomSheet(true)
},
onImportClick = onImportClick,
isImportEnabled = true
)
}
} else {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) {
DictionaryUpdateCard(
onStartDownload = onStartDownload,
modifier = backgroundModifier
)
}
DictionaryListSection(
dictionaries = dictionaries,
onToggleDictionary = onToggleDictionary,
onDeleteDictionary = onDeleteDictionary,
modifier = Modifier
.fillMaxWidth()
.then(backgroundModifier)
)
if (!isInProgress) {
DictionaryActionButtons(
onAddSource = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
setShowUrlBottomSheet(true)
},
onImportClick = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onImportClick()
},
isImportEnabled = !isImporting
)
}
}
}
}
}
}
if (showUrlBottomSheet) {
ModalBottomSheet(
onDismissRequest = {
focusManager.clearFocus(force = true)
setShowUrlBottomSheet(false)
},
sheetState = sheetState
) {
DictionaryUrlInputContent(
existingSources = sources,
onAddSources = { urls ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onAddSources(urls)
setShowUrlBottomSheet(false)
},
onCancel = {
focusManager.clearFocus(force = true)
setShowUrlBottomSheet(false)
}
)
}
}
}
@Composable
private fun DictionaryProgressSectionHost(
visible: Boolean,
progressSnapshotFlow: StateFlow<DictionaryProgressModel.Snapshot?>,
onCancel: () -> Unit,
) {
val progressSnapshot by progressSnapshotFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val targetProgress = progressSnapshot?.progress ?: 0f
val label = remember(progressSnapshot, context) {
val s = progressSnapshot ?: return@remember ""
s.renderTitle(context)
}
val progressTestTag = if (progressSnapshot?.titleRes == R.string.notification_indexing_title) {
"indexing_progress"
} else {
"download_progress"
}
DictionaryProgressSection(
visible = visible,
progressProvider = { targetProgress },
label = label,
onCancel = onCancel,
percent = progressSnapshot?.percent ?: 0,
testTag = progressTestTag,
)
}
@Composable
private fun DictionaryListSection(
dictionaries: List<Dictionary>,
onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
dictionaries.forEach { dictionary ->
DictionaryListItem(
dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) }
)
}
}
}
@@ -0,0 +1,89 @@
package com.example.research.ui.settings.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.*
import androidx.compose.ui.res.painterResource
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.research.R
@Composable
fun DictionaryProgressSection(
visible: Boolean,
progressProvider: () -> Float,
label: String,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
percent: Int,
testTag: String,
) {
val targetProgress = progressProvider().coerceIn(0f, 1f)
var monotonicTargetProgress by remember { mutableFloatStateOf(0f) }
LaunchedEffect(visible, targetProgress) {
monotonicTargetProgress = if (visible) {
maxOf(monotonicTargetProgress, targetProgress)
} else {
0f
}
}
val animatedProgress by animateFloatAsState(
targetValue = monotonicTargetProgress,
animationSpec = tween(durationMillis = 650, easing = FastOutSlowInEasing),
label = "dictionary-progress"
)
AnimatedVisibility(
visible = visible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = modifier.testTag(testTag)
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "$percent%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(MaterialTheme.shapes.small),
)
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
Icon(painterResource(R.drawable.ic_close), null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.action_cancel), style = MaterialTheme.typography.labelMedium)
}
}
}
}
@@ -0,0 +1,51 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.*
import androidx.compose.ui.res.painterResource
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.research.R
@Composable
fun DictionaryUpdateCard(
onStartDownload: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
onClick = onStartDownload
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_cloud_download),
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.dictionaries_update_available),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = stringResource(R.string.dictionaries_tap_to_update),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
}
}
@@ -0,0 +1,291 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.core.domain.model.DictionarySource
private data class UrlValidationContext(
val url: String,
val existingSources: List<DictionarySource>,
val existingPrefixes: Set<String>,
val urlsInForm: List<String>,
val urlPrefix: String?,
val errorMessages: ErrorMessages
)
private data class ErrorMessages(
val empty: String,
val duplicateInForm: String,
val duplicate: String,
val duplicatePrefix: String,
val invalidProtocol: String,
val invalidExtension: String,
val tooLong: String,
val invalidUrl: String,
val suspiciousCharacters: String
)
private fun validateDictionaryUrl(context: UrlValidationContext): String? {
with(context) {
return when {
url.isEmpty() -> null
urlsInForm.count { it == url } > 1 -> errorMessages.duplicateInForm
existingSources.any { it.urlTemplate == url } -> errorMessages.duplicate
urlPrefix != null && existingPrefixes.contains(urlPrefix) -> errorMessages.duplicatePrefix
else -> when (val result = DictionarySource.validateTemplate(url)) {
is DictionarySource.ValidationResult.Valid -> null
is DictionarySource.ValidationResult.Error -> when (result.error) {
DictionarySource.ValidationError.EMPTY -> null
DictionarySource.ValidationError.INVALID_PROTOCOL -> errorMessages.invalidProtocol
DictionarySource.ValidationError.INVALID_EXTENSION -> errorMessages.invalidExtension
DictionarySource.ValidationError.TOO_LONG -> errorMessages.tooLong
DictionarySource.ValidationError.INVALID_URL_STRUCTURE -> errorMessages.invalidUrl
DictionarySource.ValidationError.SUSPICIOUS_CHARACTERS -> errorMessages.suspiciousCharacters
}
}
}
}
}
@Composable
fun DictionaryUrlInputContent(
existingSources: List<DictionarySource>,
onAddSources: (List<String>) -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier
) {
val urls = remember { mutableStateListOf(TextFieldValue("")) }
val validationErrors = remember { mutableStateMapOf<Int, String>() }
val scrollState = rememberScrollState()
val focusManager = LocalFocusManager.current
val errorMessages = ErrorMessages(
empty = stringResource(R.string.dictionary_source_url_empty),
duplicateInForm = stringResource(R.string.dictionary_source_duplicate_in_form),
duplicate = stringResource(R.string.dictionary_source_duplicate),
duplicatePrefix = stringResource(R.string.dictionary_source_duplicate_prefix),
invalidProtocol = stringResource(R.string.dictionary_source_invalid_protocol),
invalidExtension = stringResource(R.string.dictionary_source_invalid_extension),
tooLong = stringResource(R.string.dictionary_source_too_long),
invalidUrl = stringResource(R.string.dictionary_source_invalid_url),
suspiciousCharacters = stringResource(R.string.dictionary_source_suspicious_characters)
)
// Cache extracted prefixes for existing sources to avoid repeated extraction
val existingPrefixes = remember(existingSources) {
existingSources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
}
// Cache prefixes for all input URLs to avoid repeated extraction during recomposition
val inputUrlPrefixes = remember {
derivedStateOf {
urls.associate { urlValue ->
val urlString = urlValue.text.trim()
urlString to if (urlString.isNotEmpty()) {
DictionarySource.extractPrefix(urlString)
} else null
}
}
}
Column(
modifier = modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding()
.verticalScroll(scrollState)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.dictionary_source_add_button),
style = MaterialTheme.typography.headlineSmall
)
urls.forEachIndexed { index, urlValue ->
val isLastItem = index == urls.lastIndex
val urlString = urlValue.text.trim()
val cachedPrefix = inputUrlPrefixes.value[urlString]
val realtimeError = remember(urlString, existingPrefixes, urls.size, cachedPrefix) {
validateDictionaryUrl(
UrlValidationContext(
url = urlString,
existingSources = existingSources,
existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.text.trim() },
urlPrefix = cachedPrefix,
errorMessages = errorMessages
)
)
}
val displayError = validationErrors[index] ?: realtimeError
OutlinedTextField(
value = urlValue,
onValueChange = { newValue ->
val filtered = newValue.copy(
text = newValue.text
.take(2048)
.filter { char ->
!char.isISOControl() &&
char != '\u0000' &&
char.code !in 128..<160
}
)
urls[index] = filtered
validationErrors.remove(index)
},
label = { Text(if (index == 0) stringResource(R.string.dictionary_source_url_hint) else "URL ${index + 1}") },
isError = displayError != null,
supportingText = displayError?.let { { Text(it) } },
modifier = Modifier
.fillMaxWidth()
.then(
if (index == 0) {
Modifier
.testTag("url_input_field")
.semantics { testTagsAsResourceId = true }
} else {
Modifier
}
),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next
),
trailingIcon = if (urls.size > 1) {
{
IconButton(onClick = {
urls.removeAt(index)
validationErrors.clear()
}) {
Icon(painterResource(R.drawable.ic_close), stringResource(R.string.action_remove))
}
}
} else null
)
}
TextButton(
onClick = {
urls.add(TextFieldValue(""))
},
modifier = Modifier
.align(Alignment.Start)
.testTag("add_another_url_button")
.semantics { testTagsAsResourceId = true }
) {
Icon(painterResource(R.drawable.ic_add), null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.action_add_another_url))
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = {
focusManager.clearFocus(force = true)
onCancel()
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.action_cancel))
}
TextButton(
onClick = {
validationErrors.clear()
var hasError = false
val validUrls = mutableListOf<String>()
val seenUrls = mutableSetOf<String>()
urls.forEachIndexed { index, urlValue ->
val urlString = urlValue.text.trim()
if (urlString.isBlank()) {
if (urls.size == 1) {
validationErrors[index] = errorMessages.empty
hasError = true
}
} else {
val urlPrefix = inputUrlPrefixes.value[urlString]
val error = validateDictionaryUrl(
UrlValidationContext(
url = urlString,
existingSources = existingSources,
existingPrefixes = existingPrefixes,
urlsInForm = seenUrls.toList() + urlString,
urlPrefix = urlPrefix,
errorMessages = errorMessages
)
)
if (error != null) {
validationErrors[index] = error
hasError = true
} else {
validUrls.add(urlString)
seenUrls.add(urlString)
}
}
}
if (!hasError && validUrls.isNotEmpty()) {
focusManager.clearFocus(force = true)
onAddSources(validUrls)
}
},
modifier = Modifier
.weight(1f)
.testTag("add_confirm_button")
.semantics { testTagsAsResourceId = true },
enabled = urls.any { it.text.trim().isNotBlank() } &&
urls.none { urlValue ->
val urlString = urlValue.text.trim()
if (urlString.isEmpty()) {
false
} else {
val urlPrefix = inputUrlPrefixes.value[urlString]
validateDictionaryUrl(
UrlValidationContext(
url = urlString,
existingSources = existingSources,
existingPrefixes = existingPrefixes,
urlsInForm = urls.map { it.text.trim() },
urlPrefix = urlPrefix,
errorMessages = errorMessages
)
) != null
}
}
) {
Text(stringResource(R.string.action_add))
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
@@ -0,0 +1,42 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton
@Composable
fun LanguageSelectionSection(
currentLanguage: String,
onLanguageChanged: (String) -> Unit,
modifier: Modifier = Modifier
) {
val languages = listOf(
"system" to stringResource(R.string.language_system),
"en" to stringResource(R.string.language_english),
"ru" to stringResource(R.string.language_russian)
)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
languages.forEach { (code, label) ->
val isSelected = currentLanguage == code
OutlinedChoiceButton(
text = label,
onClick = { onLanguageChanged(code) },
modifier = Modifier
.weight(1f)
.testTag("language_$code"),
selected = isSelected
)
}
}
}
@@ -0,0 +1,41 @@
package com.example.research.ui.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.components.OutlinedChoiceButton
import com.example.research.core.domain.model.AppTheme
@Composable
fun ThemeSelectionSection(
currentTheme: AppTheme,
onThemeSelected: (AppTheme) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
AppTheme.entries.forEach { theme ->
val isSelected = currentTheme == theme
OutlinedChoiceButton(
text = when (theme) {
AppTheme.LIGHT -> stringResource(R.string.theme_light)
AppTheme.DARK -> stringResource(R.string.theme_dark)
AppTheme.SYSTEM -> stringResource(R.string.theme_system)
},
onClick = { onThemeSelected(theme) },
modifier = Modifier
.weight(1f)
.testTag("theme_${theme.name.lowercase()}"),
selected = isSelected
)
}
}
}
@@ -0,0 +1,12 @@
package com.example.research.ui.theme
import androidx.compose.ui.unit.dp
object Spacing {
// Semantic spacing constants for specific UI elements
val topBarPadding = 8.dp
val topBarHeight = 56.dp
val iconStart = 32.dp
val searchTextStart = 68.dp
val listItemVertical = 8.dp
}
@@ -0,0 +1,36 @@
package com.example.research.ui.theme
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.runtime.Composable
object AppWindowInsets {
val horizontalSafeDrawing: WindowInsets
@Composable
get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
val topAndHorizontalSafeDrawing: WindowInsets
@Composable
get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
val contentWithNavigation: WindowInsets
@Composable
get() = WindowInsets.navigationBars.union(WindowInsets.ime)
val fullScreen: WindowInsets
@Composable
get() = WindowInsets.statusBars
.union(WindowInsets.navigationBars)
.union(WindowInsets.displayCutout)
val navigationOnly: WindowInsets
@Composable
get() = WindowInsets.navigationBars
}