Implement a StageIndicator to visualize vocabulary learning progress and refine the VocabularyCard UI.

This commit is contained in:
jonasgaudian
2026-02-19 17:47:44 +01:00
parent 863920143d
commit d6a9ccf4e3
6 changed files with 372 additions and 77 deletions

View File

@@ -101,7 +101,7 @@ fun DailyReviewScreen(
state = listState,
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp)
) {
items(

View File

@@ -33,11 +33,13 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AddCircleOutline
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.LocalMall
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
@@ -58,6 +60,7 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -70,6 +73,8 @@ import eu.gaudian.translator.R
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.insertBreakOpportunities
@@ -127,22 +132,29 @@ fun SelectionTopBar(
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
// 1. Close Button
IconButton(onClick = onCloseClick) {
Icon(imageVector = AppIcons.Close, contentDescription = stringResource(R.string.label_close_selection_mode))
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.d_selected, selectionCount),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
Icon(
imageVector = AppIcons.Close,
contentDescription = stringResource(R.string.label_close_selection_mode)
)
}
Row {
// 2. Title Text (Gets weight to prevent pushing icons off-screen)
Text(
text = stringResource(R.string.d_selected, selectionCount),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// 3. Action Icons Group
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onSelectAllClick) {
Icon(
imageVector = AppIcons.SelectAll,
@@ -320,6 +332,7 @@ fun AllCardsView(
vocabularyItems: List<VocabularyItem>,
allLanguages: List<Language>,
selection: Set<Long>,
stageMapping: Map<Int, VocabularyStage> = emptyMap(),
onItemClick: (VocabularyItem) -> Unit,
onItemLongClick: (VocabularyItem) -> Unit,
onDeleteClick: (VocabularyItem) -> Unit,
@@ -350,7 +363,7 @@ fun AllCardsView(
} else {
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 100.dp)
) {
@@ -359,10 +372,12 @@ fun AllCardsView(
key = { it.id }
) { item ->
val isSelected = selection.contains(item.id.toLong())
val stage = stageMapping[item.id] ?: VocabularyStage.NEW
VocabularyCard(
item = item,
allLanguages = allLanguages,
isSelected = isSelected,
stage = stage,
onItemClick = { onItemClick(item) },
onItemLongClick = { onItemLongClick(item) },
onDeleteClick = { onDeleteClick(item) }
@@ -372,14 +387,12 @@ fun AllCardsView(
}
}
/**
* Individual vocabulary card component
*/
@Composable
fun VocabularyCard(
item: VocabularyItem,
allLanguages: List<Language>,
isSelected: Boolean,
stage: VocabularyStage = VocabularyStage.NEW,
onItemClick: () -> Unit,
onItemLongClick: () -> Unit,
onDeleteClick: () -> Unit,
@@ -392,14 +405,15 @@ fun VocabularyCard(
Card(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(12.dp)) // Slightly rounder for a modern look
.combinedClickable(
onClick = onItemClick,
onLongClick = onItemLongClick
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainer,
contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.surfaceContainer
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
// Fixed the contentColor bug here:
contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
),
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
) {
@@ -410,52 +424,48 @@ fun VocabularyCard(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp) // Ensures text doesn't bleed into the trailing icon
) {
// Top row: First word + Language Pill
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = insertBreakOpportunities(item.wordFirst),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
// This modifier allows the text to wrap without squishing the pill
modifier = Modifier.weight(1f, fill = false)
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
) {
Text(
LanguagePill(
text = langFirst,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
backgroundColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
textColor = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(6.dp)) // Slightly more breathing room
// Bottom row: Second word + Language Pill
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = insertBreakOpportunities(item.wordSecond),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
// Applied to the second text as well for consistency
modifier = Modifier.weight(1f, fill = false)
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
shape = RoundedCornerShape(8.dp)
) {
Text(
LanguagePill(
text = langSecond,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
textColor = MaterialTheme.colorScheme.primary
)
}
}
}
if (isSelected) {
Icon(
@@ -464,15 +474,121 @@ fun VocabularyCard(
tint = MaterialTheme.colorScheme.primary
)
} else {
IconButton(onClick = { /* Options menu could go here */ }) {
// Stage indicator showing the vocabulary item's learning stage
StageIndicator(stage = stage)
}
}
}
}
@Composable
fun StageIndicator(
stage: VocabularyStage,
modifier: Modifier = Modifier
) {
// Convert VocabularyStage to a step number (0-6)
val step = when (stage) {
VocabularyStage.NEW -> 0
VocabularyStage.STAGE_1 -> 1
VocabularyStage.STAGE_2 -> 2
VocabularyStage.STAGE_3 -> 3
VocabularyStage.STAGE_4 -> 4
VocabularyStage.STAGE_5 -> 5
VocabularyStage.LEARNED -> 6
}
// 1. Calculate how full the ring should be (0.0 to 1.0)
val maxSteps = 6f
val progress = step / maxSteps
// 2. Determine the ring color based on the stage
val indicatorColor = when (stage) {
VocabularyStage.NEW -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
VocabularyStage.STAGE_1, VocabularyStage.STAGE_2 -> Color(0xFFE57373) // Soft Red
VocabularyStage.STAGE_3 -> Color(0xFFFFB74D) // Soft Orange
VocabularyStage.STAGE_4 -> Color(0xFFFFD54F) // Soft Yellow
VocabularyStage.STAGE_5 -> Color(0xFFAED581) // Light Green
VocabularyStage.LEARNED -> Color(0xFF81C784) // Solid Green
}
Box(
contentAlignment = Alignment.Center,
modifier = modifier.size(36.dp) // Keeps it neatly sized within the row
) {
// The background track (empty ring)
CircularProgressIndicator(
progress = { 1f },
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),
strokeWidth = 3.dp,
modifier = Modifier.fillMaxSize()
)
// The colored progress ring
if (stage != VocabularyStage.NEW) {
CircularProgressIndicator(
progress = { progress },
color = indicatorColor,
strokeWidth = 3.dp,
strokeCap = StrokeCap.Round, // Gives the progress bar nice rounded ends
modifier = Modifier.fillMaxSize()
)
}
// The center content (Number or Icon)
when (stage) {
VocabularyStage.NEW -> {
// An empty dot or small icon to denote it's untouched
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.cd_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant
imageVector = Icons.Rounded.Star, // Or any generic 'new' icon
contentDescription = "New Word",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
modifier = Modifier.size(16.dp)
)
}
VocabularyStage.LEARNED -> {
// A checkmark for mastery
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = "Learned",
tint = indicatorColor,
modifier = Modifier.size(20.dp)
)
}
else -> {
// Display the actual level number (1 through 5)
Text(
text = step.toString(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
// Extracted for consistency and cleaner code
@Composable
private fun LanguagePill(
text: String,
backgroundColor: Color,
textColor: Color
) {
if (text.isNotEmpty()) {
Surface(
color = backgroundColor,
shape = RoundedCornerShape(6.dp) // Consistent corner rounding for all pills
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = textColor,
// Guaranteed to never wrap awkwardly
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
@@ -515,13 +631,11 @@ fun CategoryCard(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
AppCard(
modifier = modifier
.fillMaxWidth()
.height(140.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier
@@ -709,6 +823,7 @@ fun VocabularyCardPreview() {
),
allLanguages = emptyList(),
isSelected = false,
stage = VocabularyStage.NEW,
onItemClick = {},
onItemLongClick = {},
onDeleteClick = {}
@@ -716,6 +831,154 @@ fun VocabularyCardPreview() {
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun VocabularyCardStage1Preview() {
MaterialTheme {
VocabularyCard(
item = VocabularyItem(
id = 2,
wordFirst = "Goodbye",
wordSecond = "Adiós",
languageFirstId = 1,
languageSecondId = 2,
createdAt = null,
features = null,
zipfFrequencyFirst = null,
zipfFrequencySecond = null
),
allLanguages = emptyList(),
isSelected = false,
stage = VocabularyStage.STAGE_1,
onItemClick = {},
onItemLongClick = {},
onDeleteClick = {}
)
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun VocabularyCardStage3Preview() {
MaterialTheme {
VocabularyCard(
item = VocabularyItem(
id = 3,
wordFirst = "Thank you",
wordSecond = "Gracias",
languageFirstId = 1,
languageSecondId = 2,
createdAt = null,
features = null,
zipfFrequencyFirst = null,
zipfFrequencySecond = null
),
allLanguages = emptyList(),
isSelected = false,
stage = VocabularyStage.STAGE_3,
onItemClick = {},
onItemLongClick = {},
onDeleteClick = {}
)
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun VocabularyCardStage5Preview() {
MaterialTheme {
VocabularyCard(
item = VocabularyItem(
id = 4,
wordFirst = "Please",
wordSecond = "Por favor",
languageFirstId = 1,
languageSecondId = 2,
createdAt = null,
features = null,
zipfFrequencyFirst = null,
zipfFrequencySecond = null
),
allLanguages = emptyList(),
isSelected = false,
stage = VocabularyStage.STAGE_5,
onItemClick = {},
onItemLongClick = {},
onDeleteClick = {}
)
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun VocabularyCardLearnedPreview() {
MaterialTheme {
VocabularyCard(
item = VocabularyItem(
id = 5,
wordFirst = "Yes",
wordSecond = "",
languageFirstId = 1,
languageSecondId = 2,
createdAt = null,
features = null,
zipfFrequencyFirst = null,
zipfFrequencySecond = null
),
allLanguages = emptyList(),
isSelected = false,
stage = VocabularyStage.LEARNED,
onItemClick = {},
onItemLongClick = {},
onDeleteClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun StageIndicatorNewPreview() {
MaterialTheme {
StageIndicator(stage = VocabularyStage.NEW)
}
}
@Preview(showBackground = true)
@Composable
fun StageIndicatorStage1Preview() {
MaterialTheme {
StageIndicator(stage = VocabularyStage.STAGE_1)
}
}
@Preview(showBackground = true)
@Composable
fun StageIndicatorStage3Preview() {
MaterialTheme {
StageIndicator(stage = VocabularyStage.STAGE_3)
}
}
@Preview(showBackground = true)
@Composable
fun StageIndicatorStage5Preview() {
MaterialTheme {
StageIndicator(stage = VocabularyStage.STAGE_5)
}
}
@Preview(showBackground = true)
@Composable
fun StageIndicatorLearnedPreview() {
MaterialTheme {
StageIndicator(stage = VocabularyStage.LEARNED)
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable

View File

@@ -139,6 +139,7 @@ fun LibraryScreen(
}
val vocabularyItems by vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList())
val stageMapping by vocabularyViewModel.stageMapping.collectAsStateWithLifecycle(initialValue = emptyMap())
// Handle export state
LaunchedEffect(exportState) {
@@ -263,6 +264,7 @@ fun LibraryScreen(
vocabularyItems = vocabularyItems,
allLanguages = allLanguages,
selection = selection,
stageMapping = stageMapping,
listState = lazyListState,
onItemClick = { item ->
if (isInSelectionMode) {

View File

@@ -195,15 +195,15 @@ private fun InteractiveLineChart(weeklyStats: List<WeeklyActivityStat>) {
Offset(i * xSpacing, height - ((stat.answeredRight * animationProgress) / yMax) * height)
}
// Path 1: Correct (Bottom, Dashed)
// Define Paths
val pathCorrect = Path().apply { smoothCurve(pointsCorrect) }
drawPath(
path = pathCorrect,
color = colorCorrect,
style = Stroke(width = 6f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 12f), 0f))
)
val fillPathCorrect = Path().apply {
smoothCurve(pointsCorrect)
lineTo(width, height)
lineTo(0f, height)
close()
}
// Path 2: Completed (Top, Solid with Fill)
val pathCompleted = Path().apply { smoothCurve(pointsCompleted) }
val fillPathCompleted = Path().apply {
smoothCurve(pointsCompleted)
@@ -212,14 +212,32 @@ private fun InteractiveLineChart(weeklyStats: List<WeeklyActivityStat>) {
close()
}
// Draw semi-transparent fills first
drawPath(
path = fillPathCompleted,
brush = Brush.verticalGradient(
colors = listOf(colorCompleted.copy(alpha = 0.3f), Color.Transparent),
colors = listOf(colorCompleted.copy(alpha = 0.25f), Color.Transparent),
startY = 0f,
endY = height
)
)
drawPath(
path = fillPathCorrect,
brush = Brush.verticalGradient(
colors = listOf(colorCorrect.copy(alpha = 0.25f), Color.Transparent),
startY = 0f,
endY = height
)
)
// Draw solid strokes on top of the fills
drawPath(
path = pathCorrect,
color = colorCorrect,
style = Stroke(width = 6f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 12f), 0f))
)
drawPath(
path = pathCompleted,
color = colorCompleted,

View File

@@ -669,10 +669,20 @@ private fun PackCard(
onGetClick: () -> Unit,
onAddToLibraryClick: () -> Unit,
modifier: Modifier = Modifier,
languageViewModel: LanguageViewModel = hiltViewModel(),
) {
val info = packState.info
val gradient = gradientForId(info.id)
// Get language names from language IDs
val languageIds = info.languageIds
val firstLanguageName by languageViewModel.getLanguageByIdFlow(languageIds.getOrNull(0)).collectAsState(initial = null)
val secondLanguageName by languageViewModel.getLanguageByIdFlow(languageIds.getOrNull(1)).collectAsState(initial = null)
val languageDisplayText = listOfNotNull(firstLanguageName?.name, secondLanguageName?.name)
.joinToString("")
.ifEmpty { info.category }
Surface(
modifier = modifier
.fillMaxWidth()
@@ -785,7 +795,7 @@ private fun PackCard(
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = info.category,
text = languageDisplayText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1

View File

@@ -164,6 +164,7 @@ fun AllCardsListScreen(
val vocabularyItems: List<VocabularyItem> = itemsToShow.ifEmpty {
vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList()).value
}
val stageMapping by vocabularyViewModel.stageMapping.collectAsStateWithLifecycle(initialValue = emptyMap())
// Handle export state
LaunchedEffect(exportState) {
@@ -298,6 +299,7 @@ fun AllCardsListScreen(
vocabularyItems = vocabularyItems,
allLanguages = allLanguages,
selection = selection,
stageMapping = stageMapping,
listState = lazyListState,
modifier = Modifier
.fillMaxSize()