refactor VocabularyMenu and FAB components to support dynamic text visibility based on scroll state and update Zipf frequency UI in VocabularyCard

This commit is contained in:
jonasgaudian
2026-02-14 02:01:00 +01:00
parent b65e16000c
commit b95a2de747
5 changed files with 75 additions and 26 deletions

View File

@@ -1,6 +1,7 @@
package eu.gaudian.translator.view.composable package eu.gaudian.translator.view.composable
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
@@ -55,7 +56,8 @@ data class FabMenuItem(
fun AppFabMenu( fun AppFabMenu(
items: List<FabMenuItem>, items: List<FabMenuItem>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String? = null title: String? = null,
showFabText: Boolean = true
) { ) {
var isMenuExpanded by remember { mutableStateOf(false) } var isMenuExpanded by remember { mutableStateOf(false) }
@@ -111,14 +113,16 @@ fun AppFabMenu(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier
.padding(horizontal = 16.dp)
.animateContentSize()
) { ) {
Icon( Icon(
imageVector = AppIcons.Add, imageVector = AppIcons.Add,
contentDescription = stringResource(R.string.cd_toggle_menu), contentDescription = stringResource(R.string.cd_toggle_menu),
modifier = Modifier.rotate(iconRotationAngle) modifier = Modifier.rotate(iconRotationAngle)
) )
if (title != null) { if (title != null && showFabText) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelLarge style = MaterialTheme.typography.labelLarge

View File

@@ -22,6 +22,7 @@ import eu.gaudian.translator.viewmodel.VocabularyViewModel
@Composable @Composable
fun VocabularyMenu( fun VocabularyMenu(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showFabText : Boolean = true
) { ) {
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
@@ -48,7 +49,7 @@ fun VocabularyMenu(
) )
) )
AppFabMenu(items = menuItems, modifier = modifier, title = stringResource(R.string.label_add_vocabulary)) AppFabMenu(items = menuItems, modifier = modifier, title = stringResource(R.string.label_add_vocabulary), showFabText = showFabText)
if (showAddVocabularyDialog) { if (showAddVocabularyDialog) {
AddVocabularyDialog( AddVocabularyDialog(

View File

@@ -88,6 +88,7 @@ fun DashboardContent(
onNavigateToCategoryDetail: (Int) -> Unit, onNavigateToCategoryDetail: (Int) -> Unit,
onNavigateToCategoryList: () -> Unit, onNavigateToCategoryList: () -> Unit,
onShowWordPairExerciseDialog: () -> Unit, onShowWordPairExerciseDialog: () -> Unit,
onScroll: (Boolean) -> Unit = {},
) { ) {
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
@@ -167,6 +168,11 @@ fun DashboardContent(
lazyListState.firstVisibleItemScrollOffset lazyListState.firstVisibleItemScrollOffset
) )
} }
// Detect scroll and notify parent
LaunchedEffect(lazyListState.isScrollInProgress) {
onScroll(lazyListState.isScrollInProgress)
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
settingsViewModel.saveDashboardScrollState( settingsViewModel.saveDashboardScrollState(

View File

@@ -4,6 +4,7 @@ package eu.gaudian.translator.view.vocabulary
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -112,6 +113,9 @@ fun MainVocabularyScreen(
var wpTrainingMode by remember { mutableStateOf(false) } var wpTrainingMode by remember { mutableStateOf(false) }
var wpDueTodayOnly by remember { mutableStateOf(false) } var wpDueTodayOnly by remember { mutableStateOf(false) }
var isScrolling by remember { mutableStateOf(false) }
if (showCustomExerciseDialog) { if (showCustomExerciseDialog) {
StartExerciseDialog( StartExerciseDialog(
onDismiss = { showCustomExerciseDialog = false }, onDismiss = { showCustomExerciseDialog = false },
@@ -293,6 +297,8 @@ fun MainVocabularyScreen(
VocabularyTab.entries.find { it.route == currentRoute } ?: VocabularyTab.Dashboard VocabularyTab.entries.find { it.route == currentRoute } ?: VocabularyTab.Dashboard
} }
val showFabText = selectedTab == VocabularyTab.Dashboard && !isScrolling
val repoEmpty = val repoEmpty =
vocabularyViewModel.vocabularyItems.collectAsState(initial = emptyList()).value.isEmpty() vocabularyViewModel.vocabularyItems.collectAsState(initial = emptyList()).value.isEmpty()
@@ -333,7 +339,8 @@ fun MainVocabularyScreen(
onNavigateToCategoryList = { onNavigateToCategoryList = {
navController.navigate("category_list_screen") navController.navigate("category_list_screen")
}, },
onShowWordPairExerciseDialog = { showWordPairExerciseDialog = true } onShowWordPairExerciseDialog = { showWordPairExerciseDialog = true },
onScroll = { isScrolling = it }
) )
} }
composable(VocabularyTab.Statistics.route) { composable(VocabularyTab.Statistics.route) {
@@ -390,7 +397,7 @@ fun MainVocabularyScreen(
.padding(16.dp), .padding(16.dp),
horizontalAlignment = Alignment.End horizontalAlignment = Alignment.End
) { ) {
VocabularyMenu(modifier = Modifier.onSizeChanged { menuHeightPx = it.height }) VocabularyMenu(modifier = Modifier.onSizeChanged { menuHeightPx = it.height }, showFabText = showFabText)
} }
// Place the FAB separately and animate its bottom padding based on the menu height // Place the FAB separately and animate its bottom padding based on the menu height
@@ -405,16 +412,19 @@ fun MainVocabularyScreen(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier
.padding(horizontal = 16.dp)
.animateContentSize()
) { ) {
Icon( Icon(
imageVector = AppIcons.Quiz, imageVector = AppIcons.Quiz,
contentDescription = null contentDescription = null
) )
if(showFabText) {
Text( Text(
text = stringResource(R.string.label_start_exercise), text = stringResource(R.string.label_start_exercise),
style = MaterialTheme.typography.labelLarge style = MaterialTheme.typography.labelLarge
) )}
} }
} }
} }

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -25,6 +26,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
@@ -43,7 +45,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -454,28 +458,52 @@ private fun FrequencyPill(zipfFrequency: Float?) {
if (zipfFrequency == null) return if (zipfFrequency == null) return
val semanticColors = MaterialTheme.semanticColors val semanticColors = MaterialTheme.semanticColors
// Determine the label and color based on the Zipf value
val (label, color) = when { val (label, color) = when {
zipfFrequency <= 3f -> Pair(stringResource(R.string.text_rare), semanticColors.wrongContainer) zipfFrequency <= 2.0f -> Pair(stringResource(R.string.text_rare), semanticColors.wrongContainer)
zipfFrequency <= 4f -> Pair(stringResource(R.string.text_infrequent), semanticColors.stageGradient2) zipfFrequency <= 3.0f -> Pair(stringResource(R.string.text_infrequent), semanticColors.stageGradient2)
zipfFrequency <= 4.5f -> Pair(stringResource(R.string.text_uncommon), semanticColors.stageGradient3) zipfFrequency <= 4.0f -> Pair(stringResource(R.string.text_uncommon), semanticColors.stageGradient3)
zipfFrequency <= 5.5f -> Pair(stringResource(R.string.text_common), semanticColors.stageGradient4) zipfFrequency <= 5.5f -> Pair(stringResource(R.string.text_common), semanticColors.stageGradient4)
zipfFrequency <= 6.5f -> Pair(stringResource(R.string.text_frequent), semanticColors.stageGradient5) zipfFrequency <= 6.0f -> Pair(stringResource(R.string.text_frequent), semanticColors.stageGradient5)
zipfFrequency <= 7.5f -> Pair(stringResource(R.string.text_very_frequent), semanticColors.stageGradient6)
else -> Pair(stringResource(R.string.text_very_frequent), semanticColors.successContainer) else -> Pair(stringResource(R.string.text_very_frequent), semanticColors.successContainer)
} }
// Normalize progress: Zipf 1.0 to 8.0 mapped to 0f to 1f
val progress = ((zipfFrequency - 1f) / (8f - 1f)).coerceIn(0f, 1f)
Column(
modifier = Modifier
.padding(horizontal = 4.dp)
.width(80.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface( Surface(
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
color = color.copy(alpha = 0.5f), color = color.copy(alpha = 0.2f),
modifier = Modifier.padding(horizontal = 4.dp)
) { ) {
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
) )
} }
Spacer(modifier = Modifier.height(4.dp))
// The "Slider" representation
LinearProgressIndicator(
progress = { progress },
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(CircleShape),
color = color,
trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
strokeCap = StrokeCap.Round
)
}
} }
@Composable @Composable