Refactor the WeeklyActivityChartWidget into an interactive smooth line chart and update vocabulary import labels.
This commit is contained in:
@@ -59,7 +59,7 @@ data class VocabularyItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun hasFeatures(): Boolean {
|
fun hasFeatures(): Boolean {
|
||||||
return !features.isNullOrBlank()
|
return !features.isNullOrBlank() && features != "{}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,7 +267,8 @@ fun TranslatorApp(
|
|||||||
"new_word",
|
"new_word",
|
||||||
"new_word_review",
|
"new_word_review",
|
||||||
"vocabulary_detail/{itemId}",
|
"vocabulary_detail/{itemId}",
|
||||||
"daily_review"
|
"daily_review",
|
||||||
|
"explore_packs"
|
||||||
) || currentRoute?.startsWith("start_exercise") == true
|
) || currentRoute?.startsWith("start_exercise") == true
|
||||||
|| currentRoute?.startsWith("vocabulary_exercise") == true
|
|| currentRoute?.startsWith("vocabulary_exercise") == true
|
||||||
val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false)
|
val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -397,12 +399,16 @@ fun BottomStatsSection(
|
|||||||
val learnedWords by viewModel.totalWordsCompleted.collectAsState()
|
val learnedWords by viewModel.totalWordsCompleted.collectAsState()
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(IntrinsicSize.Min),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Total Words
|
// Total Words
|
||||||
AppCard(
|
AppCard(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
onClick = { navController.navigate(Screen.Library.route) }
|
onClick = { navController.navigate(Screen.Library.route) }
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(20.dp)) {
|
Column(modifier = Modifier.padding(20.dp)) {
|
||||||
@@ -415,7 +421,9 @@ fun BottomStatsSection(
|
|||||||
|
|
||||||
// Learned
|
// Learned
|
||||||
AppCard(
|
AppCard(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
onClick = { navController.navigate(NavigationRoutes.STATS_LANGUAGE_PROGRESS) }
|
onClick = { navController.navigate(NavigationRoutes.STATS_LANGUAGE_PROGRESS) }
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(20.dp)) {
|
Column(modifier = Modifier.padding(20.dp)) {
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ package eu.gaudian.translator.view.stats.widgets
|
|||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -31,19 +32,31 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.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.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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.ui.theme.ThemePreviews
|
import eu.gaudian.translator.ui.theme.ThemePreviews
|
||||||
import eu.gaudian.translator.ui.theme.semanticColors
|
|
||||||
import eu.gaudian.translator.viewmodel.WeeklyActivityStat
|
import eu.gaudian.translator.viewmodel.WeeklyActivityStat
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A widget that displays weekly activity statistics in a visually appealing bar chart.
|
* 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, floating UI style.
|
* 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.
|
* @param weeklyStats A list of [WeeklyActivityStat] for the last 7 days.
|
||||||
*/
|
*/
|
||||||
@@ -51,20 +64,15 @@ import kotlinx.coroutines.delay
|
|||||||
fun WeeklyActivityChartWidget(
|
fun WeeklyActivityChartWidget(
|
||||||
weeklyStats: List<WeeklyActivityStat>
|
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) {
|
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) {
|
if (hasNoData) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -77,60 +85,275 @@ fun WeeklyActivityChartWidget(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(8.dp)
|
// Reduced horizontal padding to give the chart more space
|
||||||
|
.padding(vertical = 24.dp, horizontal = 12.dp)
|
||||||
) {
|
) {
|
||||||
WeeklyChartLegend()
|
WeeklyChartLegend()
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Row(
|
InteractiveLineChart(weeklyStats = weeklyStats)
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
.height(220.dp),
|
ChartFooter(weeklyStats = weeklyStats)
|
||||||
verticalAlignment = Alignment.Bottom
|
}
|
||||||
) {
|
}
|
||||||
// Y-Axis Labels
|
}
|
||||||
|
|
||||||
|
@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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight()
|
.height(180.dp)
|
||||||
.padding(end = 8.dp),
|
// Reduced end padding to save space
|
||||||
|
.padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
horizontalAlignment = Alignment.End
|
horizontalAlignment = Alignment.End
|
||||||
) {
|
) {
|
||||||
Text(maxValue.toString(), style = MaterialTheme.typography.labelSmall)
|
Text(
|
||||||
Text((maxValue / 2).toString(), style = MaterialTheme.typography.labelSmall)
|
text = yAxisMax.toString(),
|
||||||
Text("0", style = MaterialTheme.typography.labelSmall)
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = (yAxisMax / 2).toString(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "0",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chart Bars
|
// Right Side: Chart Area
|
||||||
Row(
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.fillMaxHeight(),
|
.height(180.dp)
|
||||||
horizontalArrangement = Arrangement.SpaceAround,
|
.pointerInput(Unit) {
|
||||||
verticalAlignment = Alignment.Bottom
|
detectTapGestures { offset ->
|
||||||
) {
|
val itemWidth = size.width / (weeklyStats.size - 1).coerceAtLeast(1)
|
||||||
weeklyStats.forEach { stat ->
|
selectedIndex = (offset.x / itemWidth).roundToInt().coerceIn(0, weeklyStats.lastIndex)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
}
|
||||||
Text(
|
.pointerInput(Unit) {
|
||||||
text = stat.day,
|
detectHorizontalDragGestures { change, _ ->
|
||||||
style = MaterialTheme.typography.bodySmall
|
val itemWidth = size.width / (weeklyStats.size - 1).coerceAtLeast(1)
|
||||||
|
selectedIndex = (change.position.x / itemWidth).roundToInt().coerceIn(0, weeklyStats.lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val width = size.width
|
||||||
|
val height = size.height
|
||||||
|
val xSpacing = width / (weeklyStats.size - 1).coerceAtLeast(1)
|
||||||
|
|
||||||
|
drawLine(gridColor, Offset(0f, 0f), Offset(width, 0f), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
|
||||||
|
drawLine(gridColor, Offset(0f, height / 2f), Offset(width, height / 2f), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
|
||||||
|
drawLine(gridColor, Offset(0f, height), Offset(width, height), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
|
||||||
|
|
||||||
|
if (animationProgress == 0f) return@Canvas
|
||||||
|
|
||||||
|
val pointsCompleted = weeklyStats.mapIndexed { i, stat ->
|
||||||
|
Offset(i * xSpacing, height - ((stat.completed * animationProgress) / yMax) * height)
|
||||||
|
}
|
||||||
|
val pointsCorrect = weeklyStats.mapIndexed { i, stat ->
|
||||||
|
Offset(i * xSpacing, height - ((stat.answeredRight * animationProgress) / yMax) * height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 1: Correct (Bottom, Dashed)
|
||||||
|
val pathCorrect = Path().apply { smoothCurve(pointsCorrect) }
|
||||||
|
drawPath(
|
||||||
|
path = pathCorrect,
|
||||||
|
color = colorCorrect,
|
||||||
|
style = Stroke(width = 6f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 12f), 0f))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Path 2: Completed (Top, Solid with Fill)
|
||||||
|
val pathCompleted = Path().apply { smoothCurve(pointsCompleted) }
|
||||||
|
val fillPathCompleted = Path().apply {
|
||||||
|
smoothCurve(pointsCompleted)
|
||||||
|
lineTo(width, height)
|
||||||
|
lineTo(0f, height)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPath(
|
||||||
|
path = fillPathCompleted,
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(colorCompleted.copy(alpha = 0.3f), Color.Transparent),
|
||||||
|
startY = 0f,
|
||||||
|
endY = height
|
||||||
|
)
|
||||||
|
)
|
||||||
|
drawPath(
|
||||||
|
path = pathCompleted,
|
||||||
|
color = colorCompleted,
|
||||||
|
style = Stroke(width = 8f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Interactive Highlights & Dual Separated Tooltips
|
||||||
|
selectedIndex?.let { index ->
|
||||||
|
val stat = weeklyStats[index]
|
||||||
|
val x = index * xSpacing
|
||||||
|
val yCompleted = height - ((stat.completed * animationProgress) / yMax) * height
|
||||||
|
val yCorrect = height - ((stat.answeredRight * animationProgress) / yMax) * height
|
||||||
|
|
||||||
|
// Vertical line marker
|
||||||
|
drawLine(
|
||||||
|
color = tooltipLineColor,
|
||||||
|
start = Offset(x, 0f),
|
||||||
|
end = Offset(x, height),
|
||||||
|
strokeWidth = 3f
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dots on lines
|
||||||
|
drawCircle(color = dotCenterColor, radius = 12f, center = Offset(x, yCompleted))
|
||||||
|
drawCircle(color = colorCompleted, radius = 7f, center = Offset(x, yCompleted))
|
||||||
|
|
||||||
|
drawCircle(color = dotCenterColor, radius = 12f, center = Offset(x, yCorrect))
|
||||||
|
drawCircle(color = colorCorrect, radius = 7f, center = Offset(x, yCorrect))
|
||||||
|
|
||||||
|
// Measure text
|
||||||
|
val textStyle = TextStyle(color = tooltipTextColor, fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||||
|
val textResCompleted = textMeasurer.measure(stat.completed.toString(), textStyle)
|
||||||
|
val textResCorrect = textMeasurer.measure(stat.answeredRight.toString(), textStyle)
|
||||||
|
|
||||||
|
val dotRadius = 5f
|
||||||
|
val gap = 6f
|
||||||
|
val padX = 12f
|
||||||
|
val padY = 8f
|
||||||
|
|
||||||
|
val w1 = padX * 2 + dotRadius * 2 + gap + textResCompleted.size.width
|
||||||
|
val h1 = padY * 2 + textResCompleted.size.height
|
||||||
|
|
||||||
|
val w2 = padX * 2 + dotRadius * 2 + gap + textResCorrect.size.width
|
||||||
|
val h2 = padY * 2 + textResCorrect.size.height
|
||||||
|
|
||||||
|
// Tooltip Overlap Prevention Logic
|
||||||
|
val completedIsHigher = yCompleted <= yCorrect
|
||||||
|
var yPosCompleted = if (completedIsHigher) yCompleted - h1 - 12f else yCompleted + 12f
|
||||||
|
var yPosCorrect = if (completedIsHigher) yCorrect + 12f else yCorrect - h2 - 12f
|
||||||
|
|
||||||
|
// Prevent clipping out of canvas bounds natively first
|
||||||
|
if (yPosCompleted < 0f && completedIsHigher) yPosCompleted = 0f
|
||||||
|
if (yPosCorrect < 0f && !completedIsHigher) yPosCorrect = 0f
|
||||||
|
if (yPosCompleted + h1 > height && !completedIsHigher) yPosCompleted = height - h1
|
||||||
|
if (yPosCorrect + h2 > height && completedIsHigher) yPosCorrect = height - h2
|
||||||
|
|
||||||
|
// Overlap resolution
|
||||||
|
val topRectY = minOf(yPosCompleted, yPosCorrect)
|
||||||
|
val topRectH = if (topRectY == yPosCompleted) h1 else h2
|
||||||
|
val bottomRectY = maxOf(yPosCompleted, yPosCorrect)
|
||||||
|
|
||||||
|
val gapBetweenTooltips = 8f
|
||||||
|
if (topRectY + topRectH + gapBetweenTooltips > bottomRectY) {
|
||||||
|
val midPointY = (yCompleted + yCorrect) / 2f
|
||||||
|
val adjustedTopY = midPointY - (topRectH + gapBetweenTooltips / 2f)
|
||||||
|
val adjustedBottomY = midPointY + (gapBetweenTooltips / 2f)
|
||||||
|
|
||||||
|
if (topRectY == yPosCompleted) {
|
||||||
|
yPosCompleted = adjustedTopY
|
||||||
|
yPosCorrect = adjustedBottomY
|
||||||
|
} else {
|
||||||
|
yPosCorrect = adjustedTopY
|
||||||
|
yPosCompleted = adjustedBottomY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final Canvas Bounds Check post-resolution
|
||||||
|
val finalMinY = minOf(yPosCompleted, yPosCorrect)
|
||||||
|
if (finalMinY < 0f) {
|
||||||
|
yPosCompleted -= finalMinY
|
||||||
|
yPosCorrect -= finalMinY
|
||||||
|
}
|
||||||
|
val finalMaxY = maxOf(yPosCompleted + h1, yPosCorrect + h2)
|
||||||
|
if (finalMaxY > height) {
|
||||||
|
val shift = finalMaxY - height
|
||||||
|
yPosCompleted -= shift
|
||||||
|
yPosCorrect -= shift
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Completed Tooltip
|
||||||
|
val t1X = (x - w1 / 2f).coerceIn(0f, width - w1)
|
||||||
|
drawRoundRect(color = tooltipBgColor, topLeft = Offset(t1X, yPosCompleted), size = Size(w1, h1), cornerRadius = CornerRadius(16f, 16f))
|
||||||
|
drawCircle(color = colorCompleted, radius = dotRadius, center = Offset(t1X + padX + dotRadius, yPosCompleted + h1 / 2f))
|
||||||
|
drawText(textLayoutResult = textResCompleted, topLeft = Offset(t1X + padX + dotRadius * 2 + gap, yPosCompleted + padY))
|
||||||
|
|
||||||
|
// Draw Correct Tooltip
|
||||||
|
val t2X = (x - w2 / 2f).coerceIn(0f, width - w2)
|
||||||
|
drawRoundRect(color = tooltipBgColor, topLeft = Offset(t2X, yPosCorrect), size = Size(w2, h2), cornerRadius = CornerRadius(16f, 16f))
|
||||||
|
drawCircle(color = colorCorrect, radius = dotRadius, center = Offset(t2X + padX + dotRadius, yPosCorrect + h2 / 2f))
|
||||||
|
drawText(textLayoutResult = textResCorrect, topLeft = Offset(t2X + padX + dotRadius * 2 + gap, yPosCorrect + padY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// X-Axis Labels (Freed from fixed widths, prevented from wrapping)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
weeklyStats.forEachIndexed { index, stat ->
|
||||||
|
val isSelected = index == selectedIndex
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stat.day.uppercase().take(3) + ".",
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 11.sp, // Slightly smaller to ensure fit across all devices
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false // Prevents the text from splitting into multiple lines
|
||||||
|
)
|
||||||
|
if (isSelected) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(2.dp)
|
||||||
|
.width(20.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Invisible spacer to prevent layout jumping when line appears
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,41 +362,29 @@ fun WeeklyActivityChartWidget(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private fun Path.smoothCurve(points: List<Offset>) {
|
||||||
private fun RowScope.Bar(value: Int, maxValue: Int, color: Color) {
|
if (points.isEmpty()) return
|
||||||
var startAnimation by remember { mutableStateOf(false) }
|
moveTo(points.first().x, points.first().y)
|
||||||
val barHeight by animateFloatAsState(
|
for (i in 1 until points.size) {
|
||||||
targetValue = if (startAnimation) value.toFloat() / maxValue.toFloat() else 0f,
|
val prev = points[i - 1]
|
||||||
animationSpec = tween(durationMillis = 1000),
|
val curr = points[i]
|
||||||
label = "barHeightAnimation"
|
val controlX = (prev.x + curr.x) / 2f
|
||||||
|
cubicTo(
|
||||||
|
controlX, prev.y,
|
||||||
|
controlX, curr.y,
|
||||||
|
curr.x, curr.y
|
||||||
)
|
)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
delay(200) // Small delay to ensure the UI is ready before animating
|
|
||||||
@Suppress("AssignedValueIsNeverRead")
|
|
||||||
startAnimation = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight(barHeight)
|
|
||||||
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
|
|
||||||
.background(color)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeeklyChartLegend() {
|
private fun WeeklyChartLegend() {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
.padding(horizontal = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
) {
|
||||||
LegendItem(color = MaterialTheme.semanticColors.stageGradient1, label = stringResource(R.string.label_added))
|
LegendItem(color = MaterialTheme.colorScheme.primary, label = stringResource(R.string.label_completed).uppercase())
|
||||||
LegendItem(color = MaterialTheme.semanticColors.stageGradient3, label = stringResource(R.string.label_completed))
|
LegendItem(color = MaterialTheme.colorScheme.tertiary, label = stringResource(R.string.label_correct).uppercase())
|
||||||
LegendItem(color = MaterialTheme.semanticColors.stageGradient5, label = stringResource(R.string.label_correct))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +396,51 @@ private fun LegendItem(color: Color, label: String) {
|
|||||||
.size(10.dp)
|
.size(10.dp)
|
||||||
.background(color, shape = CircleShape)
|
.background(color, shape = CircleShape)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(text = label, style = MaterialTheme.typography.labelMedium, fontSize = 12.sp)
|
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 +448,13 @@ private fun LegendItem(color: Color, label: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun WeeklyActivityChartWidgetPreview() {
|
fun WeeklyActivityChartWidgetPreview() {
|
||||||
val sampleStats = listOf(
|
val sampleStats = listOf(
|
||||||
WeeklyActivityStat("Mon", 10, 5, 20),
|
WeeklyActivityStat("Seg", 30, 15, 10),
|
||||||
WeeklyActivityStat("Tue", 12, 3, 15),
|
WeeklyActivityStat("Ter", 45, 20, 12),
|
||||||
WeeklyActivityStat("Wed", 8, 8, 25),
|
WeeklyActivityStat("Qua", 80, 25, 15),
|
||||||
WeeklyActivityStat("Thu", 15, 2, 18),
|
WeeklyActivityStat("Qui", 84, 35, 18),
|
||||||
WeeklyActivityStat("Fri", 5, 10, 30),
|
WeeklyActivityStat("Sex", 50, 40, 22),
|
||||||
WeeklyActivityStat("Sat", 7, 6, 22),
|
WeeklyActivityStat("Sáb", 70, 30, 20),
|
||||||
WeeklyActivityStat("Sun", 9, 4, 17)
|
WeeklyActivityStat("Dom", 60, 25, 18)
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ import eu.gaudian.translator.utils.StatusMessageId
|
|||||||
import eu.gaudian.translator.utils.StatusMessageService
|
import eu.gaudian.translator.utils.StatusMessageService
|
||||||
import eu.gaudian.translator.utils.findActivity
|
import eu.gaudian.translator.utils.findActivity
|
||||||
import eu.gaudian.translator.view.NavigationRoutes
|
import eu.gaudian.translator.view.NavigationRoutes
|
||||||
import eu.gaudian.translator.view.composable.AppActionCard
|
|
||||||
import eu.gaudian.translator.view.composable.AppButton
|
import eu.gaudian.translator.view.composable.AppButton
|
||||||
import eu.gaudian.translator.view.composable.AppCard
|
import eu.gaudian.translator.view.composable.AppCard
|
||||||
import eu.gaudian.translator.view.composable.AppIconContainer
|
import eu.gaudian.translator.view.composable.AppIconContainer
|
||||||
@@ -67,7 +66,6 @@ import eu.gaudian.translator.view.composable.AppOutlinedTextField
|
|||||||
import eu.gaudian.translator.view.composable.AppSlider
|
import eu.gaudian.translator.view.composable.AppSlider
|
||||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
import eu.gaudian.translator.view.composable.InspiringSearchField
|
import eu.gaudian.translator.view.composable.InspiringSearchField
|
||||||
import eu.gaudian.translator.view.composable.LabeledSection
|
|
||||||
import eu.gaudian.translator.view.composable.Screen
|
import eu.gaudian.translator.view.composable.Screen
|
||||||
import eu.gaudian.translator.view.composable.SourceLanguageDropdown
|
import eu.gaudian.translator.view.composable.SourceLanguageDropdown
|
||||||
import eu.gaudian.translator.view.composable.TargetLanguageDropdown
|
import eu.gaudian.translator.view.composable.TargetLanguageDropdown
|
||||||
@@ -700,12 +698,6 @@ fun ExplorePacksProminentCard(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.DriveFolderUpload,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -734,7 +726,7 @@ fun ImportCsvCard(
|
|||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.label_import_csv),
|
text = stringResource(R.string.label_import_csv_or_lists),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
@@ -745,12 +737,6 @@ fun ImportCsvCard(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.DriveFolderUpload,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ private fun VocabularyCardContent(
|
|||||||
onMoveToStageClick = onMoveToStageClick,
|
onMoveToStageClick = onMoveToStageClick,
|
||||||
onDeleteClick = onDeleteClick,
|
onDeleteClick = onDeleteClick,
|
||||||
|
|
||||||
showAnalyzeGrammarButton = item.features.isNullOrBlank(),
|
showAnalyzeGrammarButton = !item.hasFeatures(),
|
||||||
onAnalyzeGrammarClick = {
|
onAnalyzeGrammarClick = {
|
||||||
vocabularyViewModel.fetchAndApplyGrammaticalDetailsForList(listOf(item))
|
vocabularyViewModel.fetchAndApplyGrammaticalDetailsForList(listOf(item))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ class ProgressViewModel @Inject constructor(
|
|||||||
|
|
||||||
// Calculate localized day name
|
// Calculate localized day name
|
||||||
val calendarDay = ((date.dayOfWeek.ordinal + 1) % 7) + 1
|
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(
|
WeeklyActivityStat(
|
||||||
// 3. Get the actual day name from the date and take the first 3 letters.
|
// 3. Get the actual day name from the date and take the first 3 letters.
|
||||||
|
|||||||
@@ -902,7 +902,6 @@
|
|||||||
<string name="text_add_new_word_to_list">Extrahiere ein neues Wort in deine Liste</string>
|
<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_scroll_to_top">Nach oben scrollen</string>
|
||||||
<string name="cd_settings">Einstellungen</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_ai_generator">KI-Generator</string>
|
||||||
<string name="label_new_wordss">Neue Wörter</string>
|
<string name="label_new_wordss">Neue Wörter</string>
|
||||||
<string name="label_recently_added">Kürzlich hinzugefügt</string>
|
<string name="label_recently_added">Kürzlich hinzugefügt</string>
|
||||||
|
|||||||
@@ -898,7 +898,6 @@
|
|||||||
<string name="text_add_new_word_to_list">Extrair uma nova palavra para a sua lista</string>
|
<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_scroll_to_top">Rolar para o topo</string>
|
||||||
<string name="cd_settings">Configurações</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_ai_generator">Gerador de IA</string>
|
||||||
<string name="label_new_wordss">Novas Palavras</string>
|
<string name="label_new_wordss">Novas Palavras</string>
|
||||||
<string name="label_recently_added">Adicionados recentemente</string>
|
<string name="label_recently_added">Adicionados recentemente</string>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
<string name="desc_daily_review_due">%1$d words need attention</string>
|
<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_expand_your_vocabulary">Expand your vocabulary</string>
|
||||||
<string name="desc_explore_packs">Discover curated vocabulary packs</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="desc_import_csv">Import words from CSV or lists</string>
|
||||||
|
|
||||||
<string name="description">Description</string>
|
<string name="description">Description</string>
|
||||||
@@ -1168,4 +1168,5 @@
|
|||||||
|
|
||||||
<!-- Explore Packs Hint -->
|
<!-- Explore Packs Hint -->
|
||||||
<string name="hint_explore_packs_title">About Vocabulary Packs</string>
|
<string name="hint_explore_packs_title">About Vocabulary Packs</string>
|
||||||
|
<string name="label_import_csv_or_lists">Import Lists or CSV</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user