Compare commits

...

5 Commits

25 changed files with 1329 additions and 330 deletions

View File

@@ -1,4 +1,4 @@
All vocabulary lists in this section were generated using AI. While I strive for accuracy and quality, please keep in mind that these are machine-generated collections.
All vocabulary lists in this section were generated automatically. While I strive for accuracy and quality, please keep in mind that these are machine-generated collections.
I'm a single developer building and maintaining this app in my spare time. I'm passionate about creating tools that help people learn languages, but I have limited resources and time.

View File

@@ -57,6 +57,10 @@ data class VocabularyItem(
features = switchedFeaturesJson
)
}
fun hasFeatures(): Boolean {
return !features.isNullOrBlank() && features != "{}"
}
}
@Serializable

View File

@@ -55,7 +55,9 @@ class TranslationService(private val context: Context) {
}
}
private suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) {
// Public method to directly use LibreTranslate (bypasses AI)
suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) {
Log.d("libreTranslate: $text, $source, $target")
try {
val json = org.json.JSONObject().apply {
put("q", text)

View File

@@ -267,7 +267,8 @@ fun TranslatorApp(
"new_word",
"new_word_review",
"vocabulary_detail/{itemId}",
"daily_review"
"daily_review",
"explore_packs"
) || currentRoute?.startsWith("start_exercise") == true
|| currentRoute?.startsWith("vocabulary_exercise") == true
val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false)

View File

@@ -0,0 +1,124 @@
package eu.gaudian.translator.view.composable
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A compact action card with an icon and label, designed for use in rows or grids.
* Used for quick action buttons like "Explore Packs", "Import CSV", etc.
*
* @param label The text label below the icon
* @param icon The icon to display
* @param onClick Callback when the card is clicked
* @param modifier Modifier for the card
* @param height The height of the card (default 120.dp)
*/
@Composable
fun AppActionCard(
label: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
height: Dp = 120.dp,
iconContainerSize: Dp = 48.dp,
iconSize: Dp = 24.dp
) {
AppCard(
modifier = modifier.height(height),
onClick = onClick
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularIconContainer(
imageVector = icon,
size = iconContainerSize,
iconSize = iconSize
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
}
}
/**
* A section header label with consistent styling.
* Used for section titles like "Recently Added", etc.
*
* @param text The section title text
* @param modifier Modifier for the text
*/
@Composable
fun SectionLabel(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = modifier
)
}
/**
* A labeled section with an optional action button.
* Provides consistent header styling for sections with a title and optional action.
*
* @param title The section title
* @param modifier Modifier for the section header
* @param actionLabel Optional label for the action button
* @param onActionClick Optional callback for the action button
* @param content The content below the header
*/
@Composable
fun LabeledSection(
title: String,
modifier: Modifier = Modifier,
actionLabel: String? = null,
onActionClick: (() -> Unit)? = null,
content: @Composable () -> Unit
) {
Column(modifier = modifier) {
// Header row with title and optional action
if (actionLabel != null && onActionClick != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
SectionLabel(text = title)
androidx.compose.material3.TextButton(onClick = onActionClick) {
Text(actionLabel)
}
}
} else {
SectionLabel(text = title)
}
Spacer(modifier = Modifier.height(12.dp))
content()
}
}

View File

@@ -0,0 +1,81 @@
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A reusable icon container that displays an icon inside a shaped background.
* Used throughout the app for consistent icon presentation in cards, buttons, and action items.
*
* @param imageVector The icon to display
* @param modifier Modifier to be applied to the container
* @param size The size of the container (default 40.dp)
* @param iconSize The size of the icon itself (default 24.dp)
* @param shape The shape of the container (default RoundedCornerShape(12.dp))
* @param backgroundColor Background color of the container
* @param iconTint Tint color for the icon
*/
@Composable
fun AppIconContainer(
imageVector: ImageVector,
modifier: Modifier = Modifier,
size: Dp = 40.dp,
iconSize: Dp = 24.dp,
shape: androidx.compose.ui.graphics.Shape = RoundedCornerShape(12.dp),
backgroundColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant,
iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary,
contentDescription: String? = null
) {
Box(
modifier = modifier
.size(size)
.clip(shape)
.background(backgroundColor),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
tint = iconTint,
modifier = Modifier.size(iconSize)
)
}
}
/**
* A circular variant of AppIconContainer.
* Convenience wrapper for circular icon containers.
*/
@Composable
fun CircularIconContainer(
imageVector: ImageVector,
modifier: Modifier = Modifier,
size: Dp = 48.dp,
iconSize: Dp = 24.dp,
backgroundColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant,
iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary,
contentDescription: String? = null
) {
AppIconContainer(
imageVector = imageVector,
modifier = modifier,
size = size,
iconSize = iconSize,
shape = CircleShape,
backgroundColor = backgroundColor,
iconTint = iconTint,
contentDescription = contentDescription
)
}

View File

@@ -115,7 +115,7 @@ fun AppOutlinedTextField(
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
modifier = modifier,
label = label,
trailingIcon = finalTrailingIcon,
shape = ComponentDefaults.DefaultShape,

View File

@@ -0,0 +1,74 @@
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* A styled filled text input field.
* Different from AppOutlinedTextField - this uses a filled background style.
*
* @param value The input text to be shown in the text field.
* @param onValueChange The callback that is triggered when the input service updates the text.
* @param modifier The modifier to be applied to the text field.
* @param placeholder The placeholder text to display when the field is empty.
* @param enabled Whether the text field is enabled.
* @param readOnly Whether the text field is read-only.
* @param singleLine Whether the text field is single line.
* @param minLines Minimum number of lines.
* @param maxLines Maximum number of lines.
*/
@Composable
fun AppTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
singleLine: Boolean = true,
minLines: Int = 1,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
) {
val cornerRadius = 12.dp
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
placeholder = placeholder?.let {
{
Text(
text = it,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
},
shape = RoundedCornerShape(cornerRadius),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
),
singleLine = singleLine,
minLines = minLines,
maxLines = maxLines,
enabled = enabled,
readOnly = readOnly,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon
)
}

View File

@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -24,6 +23,7 @@ import androidx.core.net.toUri
import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.view.composable.AppDialog
import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.view.composable.AppSlider
import kotlin.math.roundToInt
@@ -55,7 +55,7 @@ fun RequestMorePackDialog(
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
OutlinedTextField(
AppOutlinedTextField(
value = topic,
onValueChange = { topic = it },
placeholder = { Text("e.g. Travel, Business, Cooking…") },
@@ -78,20 +78,21 @@ fun RequestMorePackDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
AppOutlinedTextField(
value = langFrom,
onValueChange = { langFrom = it },
placeholder = { Text(stringResource(R.string.label_from)) },
label = { Text(stringResource(R.string.label_from)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
AppOutlinedTextField(
value = langTo,
onValueChange = { langTo = it },
placeholder = { Text(stringResource(R.string.label_to)) },
label = { Text(stringResource(R.string.label_to)) },
singleLine = true,
modifier = Modifier.weight(1f)
)

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

@@ -5,8 +5,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -27,7 +29,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -47,6 +48,7 @@ import eu.gaudian.translator.R
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.NavigationRoutes
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.LabeledSection
import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.settings.SettingsRoutes
import eu.gaudian.translator.view.stats.widgets.WeeklyActivityChartWidget
@@ -356,20 +358,12 @@ fun WeeklyProgressSection(
val viewModel: ProgressViewModel = hiltViewModel(activity)
val weeklyActivityStats by viewModel.weeklyActivityStats.collectAsState()
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
LabeledSection(
title = stringResource(R.string.label_weekly_progress),
modifier = modifier,
actionLabel = stringResource(R.string.label_see_history),
onActionClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
) {
Text(text = stringResource(R.string.label_weekly_progress), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
TextButton(onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }) {
Text(stringResource(R.string.label_see_history), softWrap = false)
}
}
Spacer(modifier = Modifier.height(8.dp))
AppCard(
modifier = Modifier.fillMaxWidth(),
onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
@@ -405,12 +399,16 @@ fun BottomStatsSection(
val learnedWords by viewModel.totalWordsCompleted.collectAsState()
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Total Words
AppCard(
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
onClick = { navController.navigate(Screen.Library.route) }
) {
Column(modifier = Modifier.padding(20.dp)) {
@@ -423,7 +421,9 @@ fun BottomStatsSection(
// Learned
AppCard(
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
onClick = { navController.navigate(NavigationRoutes.STATS_LANGUAGE_PROGRESS) }
) {
Column(modifier = Modifier.padding(20.dp)) {

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

@@ -90,7 +90,7 @@ fun StatusWidget(
if (itemsWithoutGrammarCount > 0) {
StatusItem(
icon = AppIcons.Error,
text = stringResource(R.string.items_without_grammar_infos),
text = stringResource(R.string.label_items_without_grammar),
count = itemsWithoutGrammarCount,
onClick = onNavigateToNoGrammar,
color = MaterialTheme.colorScheme.error

View File

@@ -4,14 +4,15 @@ package eu.gaudian.translator.view.stats.widgets
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -31,19 +32,31 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.ui.theme.semanticColors
import eu.gaudian.translator.viewmodel.WeeklyActivityStat
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
/**
* A widget that displays weekly activity statistics in a visually appealing bar chart.
* It's designed to be consistent with the app's modern, floating UI style.
* A widget that displays weekly activity statistics in a visually appealing smooth line chart.
* It's designed to be consistent with the app's modern UI style using the theme's colors.
*
* @param weeklyStats A list of [WeeklyActivityStat] for the last 7 days.
*/
@@ -51,20 +64,15 @@ import kotlinx.coroutines.delay
fun WeeklyActivityChartWidget(
weeklyStats: List<WeeklyActivityStat>
) {
val maxValue = remember(weeklyStats) {
(weeklyStats.flatMap { listOf(it.newlyAdded, it.completed, it.answeredRight) }.maxOrNull() ?: 0).let {
if (it < 10) 10 else ((it / 5) + 1) * 5
}
}
val hasNoData = remember(weeklyStats) {
weeklyStats.all { it.newlyAdded == 0 && it.completed == 0 && it.answeredRight == 0 }
weeklyStats.all { it.completed == 0 && it.answeredRight == 0 }
}
if (hasNoData) {
Box(
modifier = Modifier
.fillMaxWidth(),
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
@@ -77,60 +85,293 @@ fun WeeklyActivityChartWidget(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
// Reduced horizontal padding to give the chart more space
.padding(vertical = 24.dp, horizontal = 12.dp)
) {
WeeklyChartLegend()
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(220.dp),
verticalAlignment = Alignment.Bottom
) {
// Y-Axis Labels
InteractiveLineChart(weeklyStats = weeklyStats)
Spacer(modifier = Modifier.height(24.dp))
ChartFooter(weeklyStats = weeklyStats)
}
}
}
@Composable
private fun InteractiveLineChart(weeklyStats: List<WeeklyActivityStat>) {
var selectedIndex by remember { mutableStateOf<Int?>(3) } // Default selection
val textMeasurer = rememberTextMeasurer()
val colorCompleted = MaterialTheme.colorScheme.primary
val colorCorrect = MaterialTheme.colorScheme.tertiary
val gridColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
val tooltipLineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
val dotCenterColor = MaterialTheme.colorScheme.surfaceVariant
val tooltipBgColor = MaterialTheme.colorScheme.inverseSurface
val tooltipTextColor = MaterialTheme.colorScheme.inverseOnSurface
var startAnimation by remember { mutableStateOf(false) }
val animationProgress by animateFloatAsState(
targetValue = if (startAnimation) 1f else 0f,
animationSpec = tween(durationMillis = 1000),
label = "chartAnimation"
)
LaunchedEffect(Unit) {
delay(100)
startAnimation = true
}
val yAxisMax = remember(weeklyStats) {
val max = weeklyStats.flatMap { listOf(it.completed, it.answeredRight) }.maxOrNull() ?: 0
if (max < 10) 10 else ((max / 10) + 1) * 10
}
val yMax = yAxisMax.toFloat()
Row(modifier = Modifier.fillMaxWidth()) {
// Left Side: Y-Axis Amounts
Column(
modifier = Modifier
.fillMaxHeight()
.padding(end = 8.dp),
.height(180.dp)
// Reduced end padding to save space
.padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.End
) {
Text(maxValue.toString(), style = MaterialTheme.typography.labelSmall)
Text((maxValue / 2).toString(), style = MaterialTheme.typography.labelSmall)
Text("0", style = MaterialTheme.typography.labelSmall)
Text(
text = yAxisMax.toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = (yAxisMax / 2).toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "0",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Chart Bars
Row(
// Right Side: Chart Area
Column(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.Bottom
) {
weeklyStats.forEach { stat ->
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom
) {
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.weight(1f)
.fillMaxWidth(0.8f)
) {
Bar(value = stat.newlyAdded, maxValue = maxValue, color = MaterialTheme.semanticColors.stageGradient1)
Bar(value = stat.completed, maxValue = maxValue, color = MaterialTheme.semanticColors.stageGradient3)
Bar(value = stat.answeredRight, maxValue = maxValue, color = MaterialTheme.semanticColors.stageGradient5)
.fillMaxWidth()
.height(180.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
val itemWidth = size.width / (weeklyStats.size - 1).coerceAtLeast(1)
selectedIndex = (offset.x / itemWidth).roundToInt().coerceIn(0, weeklyStats.lastIndex)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stat.day,
style = MaterialTheme.typography.bodySmall
}
.pointerInput(Unit) {
detectHorizontalDragGestures { change, _ ->
val itemWidth = size.width / (weeklyStats.size - 1).coerceAtLeast(1)
selectedIndex = (change.position.x / itemWidth).roundToInt().coerceIn(0, weeklyStats.lastIndex)
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val width = size.width
val height = size.height
val xSpacing = width / (weeklyStats.size - 1).coerceAtLeast(1)
drawLine(gridColor, Offset(0f, 0f), Offset(width, 0f), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
drawLine(gridColor, Offset(0f, height / 2f), Offset(width, height / 2f), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
drawLine(gridColor, Offset(0f, height), Offset(width, height), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
if (animationProgress == 0f) return@Canvas
val pointsCompleted = weeklyStats.mapIndexed { i, stat ->
Offset(i * xSpacing, height - ((stat.completed * animationProgress) / yMax) * height)
}
val pointsCorrect = weeklyStats.mapIndexed { i, stat ->
Offset(i * xSpacing, height - ((stat.answeredRight * animationProgress) / yMax) * height)
}
// Define Paths
val pathCorrect = Path().apply { smoothCurve(pointsCorrect) }
val fillPathCorrect = Path().apply {
smoothCurve(pointsCorrect)
lineTo(width, height)
lineTo(0f, height)
close()
}
val pathCompleted = Path().apply { smoothCurve(pointsCompleted) }
val fillPathCompleted = Path().apply {
smoothCurve(pointsCompleted)
lineTo(width, height)
lineTo(0f, height)
close()
}
// Draw semi-transparent fills first
drawPath(
path = fillPathCompleted,
brush = Brush.verticalGradient(
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,
style = Stroke(width = 8f)
)
// Interactive Highlights & Dual Separated Tooltips
selectedIndex?.let { index ->
val stat = weeklyStats[index]
val x = index * xSpacing
val yCompleted = height - ((stat.completed * animationProgress) / yMax) * height
val yCorrect = height - ((stat.answeredRight * animationProgress) / yMax) * height
// Vertical line marker
drawLine(
color = tooltipLineColor,
start = Offset(x, 0f),
end = Offset(x, height),
strokeWidth = 3f
)
// Dots on lines
drawCircle(color = dotCenterColor, radius = 12f, center = Offset(x, yCompleted))
drawCircle(color = colorCompleted, radius = 7f, center = Offset(x, yCompleted))
drawCircle(color = dotCenterColor, radius = 12f, center = Offset(x, yCorrect))
drawCircle(color = colorCorrect, radius = 7f, center = Offset(x, yCorrect))
// Measure text
val textStyle = TextStyle(color = tooltipTextColor, fontWeight = FontWeight.Bold, fontSize = 13.sp)
val textResCompleted = textMeasurer.measure(stat.completed.toString(), textStyle)
val textResCorrect = textMeasurer.measure(stat.answeredRight.toString(), textStyle)
val dotRadius = 5f
val gap = 6f
val padX = 12f
val padY = 8f
val w1 = padX * 2 + dotRadius * 2 + gap + textResCompleted.size.width
val h1 = padY * 2 + textResCompleted.size.height
val w2 = padX * 2 + dotRadius * 2 + gap + textResCorrect.size.width
val h2 = padY * 2 + textResCorrect.size.height
// Tooltip Overlap Prevention Logic
val completedIsHigher = yCompleted <= yCorrect
var yPosCompleted = if (completedIsHigher) yCompleted - h1 - 12f else yCompleted + 12f
var yPosCorrect = if (completedIsHigher) yCorrect + 12f else yCorrect - h2 - 12f
// Prevent clipping out of canvas bounds natively first
if (yPosCompleted < 0f && completedIsHigher) yPosCompleted = 0f
if (yPosCorrect < 0f && !completedIsHigher) yPosCorrect = 0f
if (yPosCompleted + h1 > height && !completedIsHigher) yPosCompleted = height - h1
if (yPosCorrect + h2 > height && completedIsHigher) yPosCorrect = height - h2
// Overlap resolution
val topRectY = minOf(yPosCompleted, yPosCorrect)
val topRectH = if (topRectY == yPosCompleted) h1 else h2
val bottomRectY = maxOf(yPosCompleted, yPosCorrect)
val gapBetweenTooltips = 8f
if (topRectY + topRectH + gapBetweenTooltips > bottomRectY) {
val midPointY = (yCompleted + yCorrect) / 2f
val adjustedTopY = midPointY - (topRectH + gapBetweenTooltips / 2f)
val adjustedBottomY = midPointY + (gapBetweenTooltips / 2f)
if (topRectY == yPosCompleted) {
yPosCompleted = adjustedTopY
yPosCorrect = adjustedBottomY
} else {
yPosCorrect = adjustedTopY
yPosCompleted = adjustedBottomY
}
}
// Final Canvas Bounds Check post-resolution
val finalMinY = minOf(yPosCompleted, yPosCorrect)
if (finalMinY < 0f) {
yPosCompleted -= finalMinY
yPosCorrect -= finalMinY
}
val finalMaxY = maxOf(yPosCompleted + h1, yPosCorrect + h2)
if (finalMaxY > height) {
val shift = finalMaxY - height
yPosCompleted -= shift
yPosCorrect -= shift
}
// Draw Completed Tooltip
val t1X = (x - w1 / 2f).coerceIn(0f, width - w1)
drawRoundRect(color = tooltipBgColor, topLeft = Offset(t1X, yPosCompleted), size = Size(w1, h1), cornerRadius = CornerRadius(16f, 16f))
drawCircle(color = colorCompleted, radius = dotRadius, center = Offset(t1X + padX + dotRadius, yPosCompleted + h1 / 2f))
drawText(textLayoutResult = textResCompleted, topLeft = Offset(t1X + padX + dotRadius * 2 + gap, yPosCompleted + padY))
// Draw Correct Tooltip
val t2X = (x - w2 / 2f).coerceIn(0f, width - w2)
drawRoundRect(color = tooltipBgColor, topLeft = Offset(t2X, yPosCorrect), size = Size(w2, h2), cornerRadius = CornerRadius(16f, 16f))
drawCircle(color = colorCorrect, radius = dotRadius, center = Offset(t2X + padX + dotRadius, yPosCorrect + h2 / 2f))
drawText(textLayoutResult = textResCorrect, topLeft = Offset(t2X + padX + dotRadius * 2 + gap, yPosCorrect + padY))
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// X-Axis Labels (Freed from fixed widths, prevented from wrapping)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
weeklyStats.forEachIndexed { index, stat ->
val isSelected = index == selectedIndex
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stat.day.uppercase().take(3) + ".",
color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp, // Slightly smaller to ensure fit across all devices
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
softWrap = false // Prevents the text from splitting into multiple lines
)
if (isSelected) {
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.height(2.dp)
.width(20.dp)
.background(MaterialTheme.colorScheme.primary)
)
} else {
// Invisible spacer to prevent layout jumping when line appears
Spacer(modifier = Modifier.height(6.dp))
}
}
}
@@ -139,41 +380,29 @@ fun WeeklyActivityChartWidget(
}
}
@Composable
private fun RowScope.Bar(value: Int, maxValue: Int, color: Color) {
var startAnimation by remember { mutableStateOf(false) }
val barHeight by animateFloatAsState(
targetValue = if (startAnimation) value.toFloat() / maxValue.toFloat() else 0f,
animationSpec = tween(durationMillis = 1000),
label = "barHeightAnimation"
private fun Path.smoothCurve(points: List<Offset>) {
if (points.isEmpty()) return
moveTo(points.first().x, points.first().y)
for (i in 1 until points.size) {
val prev = points[i - 1]
val curr = points[i]
val controlX = (prev.x + curr.x) / 2f
cubicTo(
controlX, prev.y,
controlX, curr.y,
curr.x, curr.y
)
LaunchedEffect(Unit) {
delay(200) // Small delay to ensure the UI is ready before animating
@Suppress("AssignedValueIsNeverRead")
startAnimation = true
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(barHeight)
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
.background(color)
)
}
@Composable
private fun WeeklyChartLegend() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
LegendItem(color = MaterialTheme.semanticColors.stageGradient1, label = stringResource(R.string.label_added))
LegendItem(color = MaterialTheme.semanticColors.stageGradient3, label = stringResource(R.string.label_completed))
LegendItem(color = MaterialTheme.semanticColors.stageGradient5, label = stringResource(R.string.label_correct))
LegendItem(color = MaterialTheme.colorScheme.primary, label = stringResource(R.string.label_completed).uppercase())
LegendItem(color = MaterialTheme.colorScheme.tertiary, label = stringResource(R.string.label_correct).uppercase())
}
}
@@ -185,8 +414,51 @@ private fun LegendItem(color: Color, label: String) {
.size(10.dp)
.background(color, shape = CircleShape)
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = label, style = MaterialTheme.typography.labelMedium, fontSize = 12.sp)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
letterSpacing = 0.5.sp
)
}
}
@Composable
private fun ChartFooter(weeklyStats: List<WeeklyActivityStat>) {
val bestDay = remember(weeklyStats) {
weeklyStats.maxByOrNull { it.completed + it.answeredRight }?.day?.uppercase() ?: ""
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Melhor Dia:",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Text(
text = bestDay,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
fontSize = 12.sp
)
}
}
}
}
@@ -194,13 +466,13 @@ private fun LegendItem(color: Color, label: String) {
@Composable
fun WeeklyActivityChartWidgetPreview() {
val sampleStats = listOf(
WeeklyActivityStat("Mon", 10, 5, 20),
WeeklyActivityStat("Tue", 12, 3, 15),
WeeklyActivityStat("Wed", 8, 8, 25),
WeeklyActivityStat("Thu", 15, 2, 18),
WeeklyActivityStat("Fri", 5, 10, 30),
WeeklyActivityStat("Sat", 7, 6, 22),
WeeklyActivityStat("Sun", 9, 4, 17)
WeeklyActivityStat("Seg", 30, 15, 10),
WeeklyActivityStat("Ter", 45, 20, 12),
WeeklyActivityStat("Qua", 80, 25, 15),
WeeklyActivityStat("Qui", 84, 35, 18),
WeeklyActivityStat("Sex", 50, 40, 22),
WeeklyActivityStat("Sáb", 70, 30, 20),
WeeklyActivityStat("Dom", 60, 25, 18)
)
Box(
modifier = Modifier

View File

@@ -80,6 +80,7 @@ import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppAlertDialog
import eu.gaudian.translator.view.composable.AppDialog
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.SectionLabel
import eu.gaudian.translator.view.dialogs.RequestMorePackDialog
import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.view.translation.LanguageSelectorBar
@@ -88,6 +89,7 @@ import eu.gaudian.translator.viewmodel.ImportState
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.PackDownloadState
import eu.gaudian.translator.viewmodel.PackUiState
import eu.gaudian.translator.viewmodel.TranslationViewModel
import eu.gaudian.translator.viewmodel.VocabPacksViewModel
import kotlin.math.abs
@@ -124,19 +126,168 @@ enum class PackFilter {
// ---------------------------------------------------------------------------
private val gradientPalette = listOf(
listOf(Color(0xFF1565C0), Color(0xFF42A5F5)),
listOf(Color(0xFF00695C), Color(0xFF26A69A)),
listOf(Color(0xFF6A1B9A), Color(0xFFAB47BC)),
listOf(Color(0xFFE65100), Color(0xFFFFA726)),
listOf(Color(0xFF212121), Color(0xFF546E7A)),
listOf(Color(0xFFC62828), Color(0xFFEF9A9A)),
listOf(Color(0xFF1B5E20), Color(0xFF66BB6A)),
listOf(Color(0xFF0D47A1), Color(0xFF90CAF9)),
// Original Gradients
listOf(Color(0xFF1565C0), Color(0xFF42A5F5)), // Blue
listOf(Color(0xFF00695C), Color(0xFF26A69A)), // Teal
listOf(Color(0xFF6A1B9A), Color(0xFFAB47BC)), // Purple
listOf(Color(0xFFE65100), Color(0xFFFFA726)), // Orange
listOf(Color(0xFF212121), Color(0xFF546E7A)), // Dark Grey to Blue Grey
listOf(Color(0xFFC62828), Color(0xFFEF9A9A)), // Red
listOf(Color(0xFF1B5E20), Color(0xFF66BB6A)), // Green
listOf(Color(0xFF0D47A1), Color(0xFF90CAF9)), // Deep Blue
// New Monochromatic / Material Shades
listOf(Color(0xFFAD1457), Color(0xFFF06292)), // Pink
listOf(Color(0xFF283593), Color(0xFF7986CB)), // Indigo
listOf(Color(0xFF00838F), Color(0xFF4DD0E1)), // Cyan
listOf(Color(0xFFFF8F00), Color(0xFFFFD54F)), // Amber
listOf(Color(0xFFD84315), Color(0xFFFF8A65)), // Deep Orange
listOf(Color(0xFF4E342E), Color(0xFFA1887F)), // Brown
listOf(Color(0xFF4527A0), Color(0xFF9575CD)), // Deep Purple
listOf(Color(0xFF9E9D24), Color(0xFFDCE775)), // Lime
listOf(Color(0xFF37474F), Color(0xFF90A4AE)), // Cool Grey
listOf(Color(0xFFF57F17), Color(0xFFFFF176)), // Yellow
// New Multi-Hue / Vibrant Gradients
listOf(Color(0xFF1A237E), Color(0xFF880E4F)), // Deep Blue to Deep Pink (Midnight)
listOf(Color(0xFFE65100), Color(0xFFE91E63)), // Dark Orange to Pink (Sunset)
listOf(Color(0xFF0277BD), Color(0xFF00897B)), // Light Blue to Teal (Ocean)
listOf(Color(0xFF303F9F), Color(0xFF7B1FA2)), // Indigo to Purple (Galaxy)
listOf(Color(0xFFBF360C), Color(0xFFFFCA28)), // Deep Red to Amber (Fire)
listOf(Color(0xFF004D40), Color(0xFF64FFDA)), // Dark Teal to Mint (Aqua)
listOf(Color(0xFF4A148C), Color(0xFFF50057)), // Dark Purple to Neon Pink (Cyberpunk)
listOf(Color(0xFF1B5E20), Color(0xFFC0CA33)), // Dark Green to Lime (Forest)
listOf(Color(0xFF827717), Color(0xFFFF9800)), // Olive to Orange (Autumn)
listOf(Color(0xFF01579B), Color(0xFF00E5FF)), // Navy to Neon Cyan (Electric Blue)
// Pastel / Soft Gradients
listOf(Color(0xFF80DEEA), Color(0xFFE0F7FA)), // Soft Cyan
listOf(Color(0xFFF48FB1), Color(0xFFFCE4EC)), // Soft Pink
listOf(Color(0xFFCE93D8), Color(0xFFF3E5F5)), // Soft Purple
listOf(Color(0xFFA5D6A7), Color(0xFFE8F5E9)), // Soft Green
listOf(Color(0xFF81D4FA), Color(0xFFE1F5FE)), // Light Sky Blue
listOf(Color(0xFFB39DDB), Color(0xFFEDE7F6)), // Soft Lavender
listOf(Color(0xFFFFCC80), Color(0xFFFFF3E0)), // Peach / Warm Sand
listOf(Color(0xFFA5D6A7), Color(0xFFF1F8E9)), // Pale Mint
listOf(Color(0xFFFFF59D), Color(0xFFFFFDE7)), // Soft Lemon
listOf(Color(0xFFFFAB91), Color(0xFFFBE9E7)), // Pale Coral
listOf(Color(0xFFCE93D8), Color(0xFFF3E5F5)), // Light Orchid
listOf(Color(0xFFBCAAA4), Color(0xFFEFEBE9)), // Light Taupe / Oat
listOf(Color(0xFF90CAF9), Color(0xFFE3F2FD)), // Baby Blue
listOf(Color(0xFFF48FB1), Color(0xFFFCE4EC)), // Rosewater
// Soft Multi-Hue (Two-tone Pastels)
listOf(Color(0xFFE1BEE7), Color(0xFFBBDEFB)), // Light Purple to Light Blue (Cotton Candy)
listOf(Color(0xFFFFF9C4), Color(0xFFFFCCBC)), // Pale Yellow to Pale Peach (Morning Light)
listOf(Color(0xFFB2EBF2), Color(0xFFC8E6C9)), // Pale Cyan to Pale Green (Seafoam)
listOf(Color(0xFFFFD54F), Color(0xFFFF8A65)), // Warm Sun to Soft Coral (Soft Sunset)
listOf(Color(0xFFD1C4E9), Color(0xFFF8BBD0)), // Periwinkle to Blush Pink (Twilight)
listOf(Color(0xFFC5E1A5), Color(0xFFFFF59D)), // Spring Green to Pale Yellow (Meadow)
listOf(Color(0xFF80CBC4), Color(0xFF81D4FA)), // Soft Teal to Light Blue (Glacier)
listOf(Color(0xFFF8BBD0), Color(0xFFFFE0B2)), // Soft Pink to Cream (Sorbet)
)
private fun gradientForId(id: String): List<Color> =
gradientPalette[abs(id.hashCode()) % gradientPalette.size]
// ---------------------------------------------------------------------------
// Translation cache - shared between PackCard and PackPreviewDialog, cleared on screen exit
// ---------------------------------------------------------------------------
private class TranslationCache {
// Cache key: pack ID, value: Pair(translatedName, translatedDescription)
private val cache = mutableMapOf<String, Pair<String, String>>()
fun get(packId: String): Pair<String, String>? = cache[packId]
fun put(packId: String, translated: Pair<String, String>) {
cache[packId] = translated
}
fun clear() {
cache.clear()
}
}
@Composable
private fun rememberTranslationCache(): TranslationCache {
val cache = remember { TranslationCache() }
// Clear cache when leaving the screen
DisposableEffect(Unit) {
onDispose {
cache.clear()
}
}
return cache
}
// ---------------------------------------------------------------------------
// Translation helper - translates pack name and description from English to device's locale using LibreTranslate
// ---------------------------------------------------------------------------
@Composable
private fun rememberTranslatedPackInfo(
info: eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo,
translationViewModel: TranslationViewModel,
cache: TranslationCache
): Pair<String, String> {
// Check cache first
val cached = cache.get(info.id)
if (cached != null) {
return cached
}
var translatedName by remember(info.id) { mutableStateOf(info.name) }
var translatedDescription by remember(info.id) { mutableStateOf(info.description) }
// Get device locale using Locale.getDefault() which is more reliable
val deviceLocale = remember { java.util.Locale.getDefault().language }
// Launch translation when language or content changes
LaunchedEffect(info.name, info.description, deviceLocale) {
try {
// Always translate from English to device locale
val targetCode = deviceLocale
var finalName = info.name
var finalDescription = info.description
// Translate name if not empty using LibreTranslate directly
if (info.name.isNotBlank()) {
val nameResult = translationViewModel.translateWithLibreTranslate(info.name, targetCode, "en")
if (nameResult.isSuccess) {
finalName = nameResult.getOrNull() ?: info.name
}
}
// Translate description if not empty using LibreTranslate directly
if (info.description.isNotBlank()) {
val descResult = translationViewModel.translateWithLibreTranslate(info.description, targetCode, "en")
if (descResult.isSuccess) {
finalDescription = descResult.getOrNull() ?: info.description
}
}
// Update state
translatedName = finalName
translatedDescription = finalDescription
// Store in cache
cache.put(info.id, Pair(finalName, finalDescription))
} catch (e: Exception) {
Log.e(TAG, "Translation failed for pack ${info.id}: ${e.message}")
// Keep original text on failure
}
}
return Pair(translatedName, translatedDescription)
}
// ---------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------
@@ -153,6 +304,7 @@ fun ExplorePacksScreen(
val vocabPacksViewModel: VocabPacksViewModel = hiltViewModel()
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val translationViewModel: TranslationViewModel = hiltViewModel(viewModelStoreOwner = activity)
val packs by vocabPacksViewModel.packs.collectAsState()
val isLoadingManifest by vocabPacksViewModel.isLoadingManifest.collectAsState()
@@ -183,6 +335,9 @@ fun ExplorePacksScreen(
}
}
// Translation cache - shared between PackCard and PackPreviewDialog
val translationCache = rememberTranslationCache()
// Auto-open conflict dialog once a queued download finishes
LaunchedEffect(packs, pendingImportPackId) {
val id = pendingImportPackId ?: return@LaunchedEffect
@@ -227,10 +382,10 @@ fun ExplorePacksScreen(
}
}
// Filtered + sorted pack list
// Filtered + sorted pack list - also search through translated names
val filteredPacks = remember(
packs, selectedFilter, searchQuery,
selectedSourceLanguage, selectedTargetLanguage
selectedSourceLanguage, selectedTargetLanguage, translationCache
) {
val srcId = selectedSourceLanguage?.nameResId
val tgtId = selectedTargetLanguage?.nameResId
@@ -247,9 +402,16 @@ fun ExplorePacksScreen(
(tgtId == null || ids.contains(tgtId))
}
val matchSearch = searchQuery.isBlank() ||
// Search in both original English and translated names
val translated = translationCache.get(info.id)
val matchSearch = if (searchQuery.isBlank()) {
true
} else {
info.name.contains(searchQuery, ignoreCase = true) ||
info.category.contains(searchQuery, ignoreCase = true)
info.category.contains(searchQuery, ignoreCase = true) ||
(translated?.first?.contains(searchQuery, ignoreCase = true) == true) ||
(translated?.second?.contains(searchQuery, ignoreCase = true) == true)
}
val matchFilter = when (val code = selectedFilter.cefrCode) {
null -> true // All or Newest handled by sort below
@@ -365,11 +527,7 @@ fun ExplorePacksScreen(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(R.string.label_available_collections),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
SectionLabel(text = stringResource(R.string.label_available_collections))
if (!isLoadingManifest && packs.isNotEmpty()) {
Text(
stringResource(R.string.label_d_packs, filteredPacks.size),
@@ -453,6 +611,9 @@ fun ExplorePacksScreen(
items(filteredPacks, key = { it.info.id }) { packState ->
PackCard(
packState = packState,
languageViewModel = languageViewModel,
translationViewModel = translationViewModel,
translationCache = translationCache,
onCardClick = {
previewPack = packState
when (packState.downloadState) {
@@ -512,6 +673,9 @@ fun ExplorePacksScreen(
if (preview != null) {
PackPreviewDialog(
packState = preview,
languageViewModel = languageViewModel,
translationViewModel = translationViewModel,
translationCache = translationCache,
onDismiss = { previewPack = null },
onGetClick = {
pendingImportPackId = preview.info.id
@@ -668,14 +832,30 @@ private fun PackConflictStrategyOption(
@Composable
private fun PackCard(
packState: PackUiState,
languageViewModel: LanguageViewModel,
translationViewModel: TranslationViewModel,
translationCache: TranslationCache,
onCardClick: () -> Unit,
onGetClick: () -> Unit,
onAddToLibraryClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
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 }
// Get translated name and description
val (translatedName, translatedDescription) = rememberTranslatedPackInfo(info, translationViewModel, translationCache)
Surface(
modifier = modifier
.fillMaxWidth()
@@ -781,14 +961,14 @@ private fun PackCard(
// ── Pack info ─────────────────────────────────────────────────────
Column(modifier = Modifier.padding(10.dp)) {
Text(
text = info.name,
text = translatedName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 2
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = info.category,
text = languageDisplayText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
@@ -886,6 +1066,9 @@ private fun PackCard(
@Composable
private fun PackPreviewDialog(
packState: PackUiState,
languageViewModel: LanguageViewModel,
translationViewModel: TranslationViewModel,
translationCache: TranslationCache,
onDismiss: () -> Unit,
onGetClick: () -> Unit,
onAddToLibraryClick: () -> Unit,
@@ -895,9 +1078,12 @@ private fun PackPreviewDialog(
val gradient = gradientForId(info.id)
var selectedItem by remember { mutableStateOf<VocabularyItem?>(null) }
// Get translated name and description
val (translatedName, translatedDescription) = rememberTranslatedPackInfo(info, translationViewModel, translationCache)
AppDialog(
onDismissRequest = onDismiss,
title = { Text(info.name, fontWeight = FontWeight.Bold) },
title = { Text(translatedName, fontWeight = FontWeight.Bold) },
) {
// ── Gradient banner ───────────────────────────────────────────
Box(
@@ -925,18 +1111,18 @@ private fun PackPreviewDialog(
Column(modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) {
Text(info.emoji, fontSize = 32.sp)
Text(
"${info.category} · ${stringResource(R.string.text_d_cards, info.itemCount)}",
style = MaterialTheme.typography.labelMedium,
"$translatedName · ${stringResource(R.string.text_d_cards, info.itemCount)}",
style = MaterialTheme.typography.labelLarge,
color = Color.White.copy(alpha = 0.85f)
)
}
}
// ── Description ───────────────────────────────────────────────
if (info.description.isNotBlank()) {
if (translatedDescription.isNotBlank()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = info.description,
text = translatedDescription,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -31,8 +30,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -46,7 +43,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
@@ -63,8 +59,10 @@ import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.NavigationRoutes
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIconContainer
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.view.composable.AppSlider
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.InspiringSearchField
@@ -226,6 +224,14 @@ fun NewWordScreen(
Spacer(modifier = Modifier.height(16.dp))
// Explore Packs - Prominent full-width card at top
ExplorePacksProminentCard(
onClick = { navController.navigate(NavigationRoutes.EXPLORE_PACKS) }
)
Spacer(modifier = Modifier.height(24.dp))
// AI Generator Card
AIGeneratorCard(
category = category,
onCategoryChange = { category = it },
@@ -245,6 +251,7 @@ fun NewWordScreen(
Spacer(modifier = Modifier.height(24.dp))
// Add Manually Card
AddManuallyCard(
languageViewModel = languageViewModel,
vocabularyViewModel = vocabularyViewModel,
@@ -252,11 +259,9 @@ fun NewWordScreen(
Spacer(modifier = Modifier.height(24.dp))
BottomActionCardsRow(
onExplorePsClick = {
navController.navigate(NavigationRoutes.EXPLORE_PACKS)
},
onImportCsvClick = {
// Import CSV - Full width card at bottom
ImportCsvCard(
onClick = {
navController.navigate("settings_vocabulary_repository_options")
}
)
@@ -464,7 +469,11 @@ fun AIGeneratorCard(
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
SourceLanguageDropdown(
@@ -556,26 +565,16 @@ fun AddManuallyCard(
modifier = modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(24.dp)) {
// Header Row
// Header Row - Using reusable AppIconContainer
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.EditNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
AppIconContainer(
imageVector = Icons.Default.EditNote
)
}
Spacer(modifier = Modifier.width(16.dp))
Text(
text = stringResource(R.string.label_add_vocabulary),
@@ -588,37 +587,19 @@ fun AddManuallyCard(
Spacer(modifier = Modifier.height(24.dp))
// Input Fields
TextField(
// Input Fields - Using AppOutlinedTextField
AppOutlinedTextField(
value = wordText,
onValueChange = { wordText = it },
placeholder = { Text(stringResource(R.string.text_label_word), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface, // Very dark background
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
singleLine = true
placeholder = { Text(stringResource(R.string.text_label_word)) }
)
Spacer(modifier = Modifier.height(16.dp))
TextField(
AppOutlinedTextField(
value = translationText,
onValueChange = { translationText = it },
placeholder = { Text(stringResource(R.string.text_translation), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
singleLine = true
placeholder = { Text(stringResource(R.string.text_translation)) }
)
Spacer(modifier = Modifier.height(16.dp))
@@ -653,7 +634,7 @@ fun AddManuallyCard(
Spacer(modifier = Modifier.height(24.dp))
// Add to List Button (Darker variant)
// Add to List Button
AppButton(
onClick = {
val newItem = VocabularyItem(
@@ -682,83 +663,79 @@ fun AddManuallyCard(
}
}
// --- Explore Packs Prominent Card (Full width at top) ---
@Composable
fun BottomActionCardsRow(
modifier: Modifier = Modifier,
onExplorePsClick: () -> Unit,
onImportCsvClick: () -> Unit
fun ExplorePacksProminentCard(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
AppCard(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
onClick = onClick
) {
// Explore Packs Card
AppCard(
Row(
modifier = Modifier
.weight(1f)
.height(120.dp),
onClick = onExplorePsClick
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
AppIconContainer(
imageVector = AppIcons.Vocabulary,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
size = 56.dp,
iconSize = 28.dp
)
}
Spacer(modifier = Modifier.height(12.dp))
@Suppress("HardCodedStringLiteral")
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Explore Packs",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
}
}
// Import CSV Card
AppCard(
modifier = Modifier
.weight(1f)
.height(120.dp),
onClick = onImportCsvClick
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.DriveFolderUpload,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Import Lists or CSV",
style = MaterialTheme.typography.labelLarge,
text = stringResource(R.string.title_explore_packs),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.desc_explore_packs),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// --- Import CSV Card (Full width at bottom) ---
@Composable
fun ImportCsvCard(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
AppCard(
modifier = modifier.fillMaxWidth(),
onClick = onClick
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIconContainer(
imageVector = Icons.Default.DriveFolderUpload,
size = 56.dp,
iconSize = 28.dp
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.label_import_csv_or_lists),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.desc_import_csv),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

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()

View File

@@ -435,7 +435,7 @@ private fun VocabularyCardContent(
onMoveToStageClick = onMoveToStageClick,
onDeleteClick = onDeleteClick,
showAnalyzeGrammarButton = item.features.isNullOrBlank(),
showAnalyzeGrammarButton = !item.hasFeatures(),
onAnalyzeGrammarClick = {
vocabularyViewModel.fetchAndApplyGrammaticalDetailsForList(listOf(item))
},

View File

@@ -203,7 +203,7 @@ class ProgressViewModel @Inject constructor(
// Calculate localized day name
val calendarDay = ((date.dayOfWeek.ordinal + 1) % 7) + 1
val localizedDay = DateFormatSymbols.getInstance(Locale.getDefault()).shortWeekdays[calendarDay].uppercase()
val localizedDay = DateFormatSymbols.getInstance(Locale.getDefault()).weekdays[calendarDay]
WeeklyActivityStat(
// 3. Get the actual day name from the date and take the first 3 letters.

View File

@@ -177,6 +177,17 @@ class TranslationViewModel @Inject constructor(
}
}
// Direct LibreTranslate without AI - for translating pack names/descriptions
suspend fun translateWithLibreTranslate(text: String, targetLanguageCode: String, sourceLanguageCode: String?): Result<String> {
// If source and target are the same, return the original text without calling the API
val sourceCode = sourceLanguageCode?.lowercase() ?: "en"
val targetCode = targetLanguageCode.lowercase()
if (sourceCode == targetCode) {
return Result.success(text)
}
return translationService.libreTranslate(text, sourceLanguageCode, targetLanguageCode, 1)
}
suspend fun getMultipleTranslations(sentence: String, contextPhrase: String? = null): Result<List<String>> {
return translationService.getMultipleSynonyms(sentence, contextPhrase)
.also { result ->

View File

@@ -1327,7 +1327,7 @@ class VocabularyViewModel @Inject constructor(
val itemsWithoutGrammarCount: StateFlow<Int> = vocabularyItems
.map { items ->
items.count { it.features.isNullOrEmpty() }
items.count { it.hasFeatures() }
}
.stateIn(
scope = viewModelScope,

View File

@@ -419,7 +419,6 @@
<string name="label_all_types">Alle Typen</string>
<string name="filter_and_sort">Filtern und Sortieren</string>
<string name="language_with_id_d_not_found">Sprache mit ID %1$d nicht gefunden</string>
<string name="items_without_grammar_infos">Einträge ohne Grammatikinfos</string>
<string name="resolve_missing_language_id">Fehlende Sprach-ID auflösen: %1$d</string>
<string name="found_d_items_using_this_missing_language_id">%1$d Einträge mit dieser fehlenden Sprach-ID gefunden.</string>
<string name="hide_affected_items">Betroffene Einträge ausblenden</string>
@@ -903,7 +902,6 @@
<string name="text_add_new_word_to_list">Extrahiere ein neues Wort in deine Liste</string>
<string name="cd_scroll_to_top">Nach oben scrollen</string>
<string name="cd_settings">Einstellungen</string>
<string name="label_import_csv">CSV importieren</string>
<string name="label_ai_generator">KI-Generator</string>
<string name="label_new_wordss">Neue Wörter</string>
<string name="label_recently_added">Kürzlich hinzugefügt</string>

View File

@@ -415,7 +415,6 @@
<string name="label_all_types">Todos os Tipos</string>
<string name="filter_and_sort">Filtrar e Ordenar</string>
<string name="language_with_id_d_not_found">Idioma com id %1$d não encontrado</string>
<string name="items_without_grammar_infos">Itens sem infos de gramática</string>
<string name="resolve_missing_language_id">Resolver ID de Idioma Ausente: %1$d</string>
<string name="found_d_items_using_this_missing_language_id">Encontrados %1$d itens usando este ID de idioma ausente.</string>
<string name="hide_affected_items">Ocultar Itens Afetados</string>
@@ -899,7 +898,6 @@
<string name="text_add_new_word_to_list">Extrair uma nova palavra para a sua lista</string>
<string name="cd_scroll_to_top">Rolar para o topo</string>
<string name="cd_settings">Configurações</string>
<string name="label_import_csv">Importar CSV</string>
<string name="label_ai_generator">Gerador de IA</string>
<string name="label_new_wordss">Novas Palavras</string>
<string name="label_recently_added">Adicionados recentemente</string>

View File

@@ -77,6 +77,8 @@
<string name="desc_daily_review_due">%1$d words need attention</string>
<string name="desc_expand_your_vocabulary">Expand your vocabulary</string>
<string name="desc_explore_packs">Discover lists to download</string>
<string name="desc_import_csv">Import words from CSV or lists</string>
<string name="description">Description</string>
@@ -208,7 +210,7 @@
<string name="item_id">Item ID: %1$d</string>
<string name="items">%1$d items</string>
<string name="items_without_grammar_infos">Items without grammar infos</string>
<string name="label_items_without_grammar">Items without grammar infos</string>
<string name="keep_both">Keep Both</string>
@@ -1166,4 +1168,5 @@
<!-- Explore Packs Hint -->
<string name="hint_explore_packs_title">About Vocabulary Packs</string>
<string name="label_import_csv_or_lists">Import Lists or CSV</string>
</resources>