Compare commits
5 Commits
b75f5f32a0
...
95dfd3c7eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95dfd3c7eb | ||
|
|
d6a9ccf4e3 | ||
|
|
863920143d | ||
|
|
15d03ef57f | ||
|
|
f737657cdb |
@@ -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.
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ data class VocabularyItem(
|
||||
features = switchedFeaturesJson
|
||||
)
|
||||
}
|
||||
|
||||
fun hasFeatures(): Boolean {
|
||||
return !features.isNullOrBlank() && features != "{}"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -115,7 +115,7 @@ fun AppOutlinedTextField(
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier = modifier,
|
||||
label = label,
|
||||
trailingIcon = finalTrailingIcon,
|
||||
shape = ComponentDefaults.DefaultShape,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
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))
|
||||
|
||||
LabeledSection(
|
||||
title = stringResource(R.string.label_weekly_progress),
|
||||
modifier = modifier,
|
||||
actionLabel = stringResource(R.string.label_see_history),
|
||||
onActionClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
|
||||
) {
|
||||
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)) {
|
||||
|
||||
@@ -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) {
|
||||
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
|
||||
// 1. Close Button
|
||||
IconButton(onClick = onCloseClick) {
|
||||
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,50 +424,46 @@ 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(
|
||||
text = langFirst,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
LanguagePill(
|
||||
text = langFirst,
|
||||
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(
|
||||
text = langSecond,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
LanguagePill(
|
||||
text = langSecond,
|
||||
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
|
||||
textColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,18 +474,124 @@ fun VocabularyCard(
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else {
|
||||
IconButton(onClick = { /* Options menu could go here */ }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.cd_options),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
// 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.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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid view of categories
|
||||
*/
|
||||
@@ -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 = "Sí",
|
||||
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
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
|
||||
.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(
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// Right Side: Chart Area
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures { change, _ ->
|
||||
val itemWidth = size.width / (weeklyStats.size - 1).coerceAtLeast(1)
|
||||
selectedIndex = (change.position.x / itemWidth).roundToInt().coerceIn(0, weeklyStats.lastIndex)
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Y-Axis Labels
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(end = 8.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)
|
||||
}
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val xSpacing = width / (weeklyStats.size - 1).coerceAtLeast(1)
|
||||
|
||||
// Chart Bars
|
||||
Row(
|
||||
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)
|
||||
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
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stat.day,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(200) // Small delay to ensure the UI is ready before animating
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
startAnimation = true
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
imageVector = AppIcons.Vocabulary,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
AppIconContainer(
|
||||
imageVector = AppIcons.Vocabulary,
|
||||
size = 56.dp,
|
||||
iconSize = 28.dp
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -435,7 +435,7 @@ private fun VocabularyCardContent(
|
||||
onMoveToStageClick = onMoveToStageClick,
|
||||
onDeleteClick = onDeleteClick,
|
||||
|
||||
showAnalyzeGrammarButton = item.features.isNullOrBlank(),
|
||||
showAnalyzeGrammarButton = !item.hasFeatures(),
|
||||
onAnalyzeGrammarClick = {
|
||||
vocabularyViewModel.fetchAndApplyGrammaticalDetailsForList(listOf(item))
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user