Initial ReSearch export
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+263
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+291
@@ -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))
|
||||
}
|
||||
}
|
||||
+42
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.example.research.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.research.R
|
||||
import com.example.research.common.ui.components.OutlinedChoiceButton
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user