From 33cbcedbecb07ce85ab2d534590568d45d3b9004 Mon Sep 17 00:00:00 2001 From: chindaronit Date: Sat, 18 Jul 2026 18:29:22 +0530 Subject: [PATCH 1/2] feat: Added multiple analytics items for todo including: Consistency metrics for items Weekly Item completion card This month completion card --- .../java/com/flux/data/dao/TodoInstanceDao.kt | 3 + .../java/com/flux/data/model/TodoModel.kt | 46 ++ .../data/repository/TodoRepositoryImpl.kt | 18 +- .../main/java/com/flux/ui/common/Charts.kt | 482 ++++++++++++++++++ .../ui/screens/analytics/AnalyticsScreen.kt | 140 +---- .../flux/ui/screens/habits/HabitComponents.kt | 317 +----------- .../flux/ui/screens/todo/TodoComponents.kt | 290 ++++++++++- .../com/flux/ui/screens/todo/TodoDetail.kt | 14 +- 8 files changed, 855 insertions(+), 455 deletions(-) create mode 100644 app/src/main/java/com/flux/ui/common/Charts.kt diff --git a/app/src/main/java/com/flux/data/dao/TodoInstanceDao.kt b/app/src/main/java/com/flux/data/dao/TodoInstanceDao.kt index 14c19d1..39cb763 100644 --- a/app/src/main/java/com/flux/data/dao/TodoInstanceDao.kt +++ b/app/src/main/java/com/flux/data/dao/TodoInstanceDao.kt @@ -17,6 +17,9 @@ interface TodoInstanceDao { @Query("SELECT * FROM TodoInstance") fun loadAllInstances(): List + @Query("SELECT * FROM TodoInstance WHERE todoId = :todoId AND instanceDate = :date LIMIT 1") + suspend fun loadInstanceForDate(todoId: String, date: Long): TodoInstance? + @Query("DELETE FROM TodoInstance WHERE workspaceId = :workspaceId") suspend fun deleteAllWorkspaceInstance(workspaceId: String) diff --git a/app/src/main/java/com/flux/data/model/TodoModel.kt b/app/src/main/java/com/flux/data/model/TodoModel.kt index c508b24..65454c4 100644 --- a/app/src/main/java/com/flux/data/model/TodoModel.kt +++ b/app/src/main/java/com/flux/data/model/TodoModel.kt @@ -25,6 +25,8 @@ data class TodoModel( @Serializable data class TodoItem( val id: String = UUID.randomUUID().toString(), + + val value: String = "", val isChecked: Boolean = false ) @@ -53,6 +55,50 @@ data class TodoInstance( val items: List = emptyList() ) +data class ItemConsistency( + val itemId: String, + val label: String, + val checkedCount: Int, + val totalCount: Int, + val isRemoved: Boolean +) { + val percentage: Float + get() = if (totalCount == 0) 0f else checkedCount.toFloat() / totalCount +} + +fun TodoModel.calculateItemConsistency( + instances: List +): List { + val templateIds = items.map { it.id }.toSet() + val labelMap = items.associateBy({ it.id }, { it.value }) + val counts = mutableMapOf>() // itemId -> (checkedCount, totalCount) + + instances.forEach { instance -> + instance.items.forEach { item -> + val (checked, total) = counts.getOrDefault(item.id, 0 to 0) + counts[item.id] = (checked + if (item.isChecked) 1 else 0) to (total + 1) + } + } + + return counts.map { (id, pair) -> + ItemConsistency( + itemId = id, + label = labelMap[id] ?: item(id, instances) ?: "Unknown item", + checkedCount = pair.first, + totalCount = pair.second, + isRemoved = id !in templateIds + ) + }.sortedByDescending { it.percentage } +} + +private fun item(itemId: String, instances: List): String? { + return instances + .sortedByDescending { it.instanceDate } + .firstNotNullOfOrNull { instance -> + instance.items.firstOrNull { it.id == itemId }?.value + } +} + fun TodoInstance.isCompleted(): Boolean { return items.all { it.isChecked } } diff --git a/app/src/main/java/com/flux/data/repository/TodoRepositoryImpl.kt b/app/src/main/java/com/flux/data/repository/TodoRepositoryImpl.kt index 403c980..23ddf1b 100644 --- a/app/src/main/java/com/flux/data/repository/TodoRepositoryImpl.kt +++ b/app/src/main/java/com/flux/data/repository/TodoRepositoryImpl.kt @@ -31,7 +31,23 @@ class TodoRepositoryImpl @Inject constructor( } override suspend fun upsertList(list: TodoModel) { - return withContext(Dispatchers.IO) { dao.upsertList(list) } + return withContext(Dispatchers.IO) { + dao.upsertList(list) + + val todayEpoch = LocalDate.now().toEpochDay() + val todayInstance = instanceDao.loadInstanceForDate(list.id, todayEpoch) ?: return@withContext + + val existingById = todayInstance.items.associateBy { it.id } + val syncedItems = list.items.map { templateItem -> + existingById[templateItem.id] + ?.copy(value = templateItem.value) // keep isChecked, pick up rename + ?: templateItem.copy(isChecked = false) // newly added item + } + + if (syncedItems != todayInstance.items) { + instanceDao.upsertTodoInstance(todayInstance.copy(items = syncedItems)) + } + } } override suspend fun deleteList(list: TodoModel) { diff --git a/app/src/main/java/com/flux/ui/common/Charts.kt b/app/src/main/java/com/flux/ui/common/Charts.kt new file mode 100644 index 0000000..de7ea1e --- /dev/null +++ b/app/src/main/java/com/flux/ui/common/Charts.kt @@ -0,0 +1,482 @@ +package com.flux.ui.common + +import androidx.compose.foundation.Canvas +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.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pending +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.Paint +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +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 +import androidx.compose.ui.unit.sp +import com.flux.R +import com.flux.ui.screens.settings.shapeManager +import java.time.DayOfWeek +import java.time.LocalDate + +@Composable +fun HeatMapCard( + radius: Int, + title: String, + description: String, + boxSize: Dp, + intensityParam: Int, + lazyListState: LazyListState, + weekColumns: List>, + heatMap: Map +){ + Card( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + shape = shapeManager(radius = radius*2), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) + ) + ) { + Column( + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + title, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + if(description.isNotBlank()){ + Text( + description, + style = MaterialTheme.typography.labelMedium + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .width(boxSize) + .padding(top = 26.dp, end = 2.dp) + ) { + DayOfWeek.entries.forEach { day -> + Box( + modifier = Modifier + .width(boxSize + 12.dp) + .height(boxSize), + contentAlignment = Alignment.CenterStart + ) { + Text(text = day.name.take(3), fontSize = 9.sp) + } + } + } + + // Combined month + heatmap + LazyRow( + state = lazyListState, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.fillMaxWidth() + ) { + itemsIndexed(weekColumns) { index, columnDates -> + val firstDate = columnDates.firstOrNull() + val month = firstDate?.month + + // Show month label if this is the first week of the month + val showMonth = + month != null && (index == 0 || weekColumns.getOrNull(index - 1)?.firstOrNull()?.month != month) + + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Month label on top (only once per month) + Box( + modifier = Modifier.height(24.dp), + contentAlignment = Alignment.Center + ) { + if (showMonth) { + Text( + text = firstDate.month.name.take(3), + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + + // Heatmap boxes + columnDates.forEach { date -> + if (date != null) { + val count = heatMap[date] ?: 0 + val intensity = (count / if (intensityParam > 0) intensityParam.toFloat() else 2f).coerceIn(0f, 1f) + val color = lerp( + MaterialTheme.colorScheme.primary.copy(alpha = 0.08f), + MaterialTheme.colorScheme.primary, + intensity + ) + Box( + modifier = Modifier + .size(boxSize) + .background(color, RoundedCornerShape(3.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = date.dayOfMonth.toString(), + fontSize = 9.sp, + color = if (intensity > 0.5f) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface + ) + } + } else { + Box(modifier = Modifier.size(boxSize)) + } + } + } + } + } + } + } + } +} + +@Composable +fun WeeklyProgressChart( + radius: Int, + modifier: Modifier = Modifier, + percentages: List +){ + val primaryColor = MaterialTheme.colorScheme.primary + val primaryAlpha = primaryColor.copy(alpha = 0.4f) + + val today = LocalDate.now() + val startOfWeek = today.with(DayOfWeek.MONDAY) + val weekDays = (0..6).map { startOfWeek.plusDays(it.toLong()) } + + val dayLabels = listOf( + stringResource(R.string.monday_short), + stringResource(R.string.tuesday_short), + stringResource(R.string.wednesday_short), + stringResource(R.string.thursday_short), + stringResource(R.string.friday_short), + stringResource(R.string.saturday_short), + stringResource(R.string.sunday_short) + ) + Card( + modifier = Modifier.fillMaxWidth(), + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ), + onClick = {} + ) { + Column(modifier = modifier.padding(12.dp)) { + Text( + stringResource(R.string.weekly_habit_completion), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp, top = 8.dp) + ) + + Canvas( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp) + .height(200.dp) + ) { + val leftPadding = 32.dp.toPx() + val bottomPadding = 32.dp.toPx() + val widthPerPoint = (size.width - leftPadding) / (weekDays.size - 1) + val height = size.height - bottomPadding + + // Vertical percentage labels + for (i in 0..4) { + val y = height - i * height / 4 + drawContext.canvas.nativeCanvas.apply { + drawText( + "${i * 25}%", + leftPadding - 14.dp.toPx(), + y, + android.graphics.Paint().apply { + color = primaryColor.toArgb() + textSize = 12.sp.toPx() + textAlign = android.graphics.Paint.Align.RIGHT + } + ) + } + } + + // Line path + val linePath = Path().apply { + percentages.forEachIndexed { index, percentage -> + val x = leftPadding + index * widthPerPoint + val y = height - (percentage / 100f * height) + if (index == 0) moveTo(x, y) else lineTo(x, y) + } + } + + // Shadow under-line + val shadowPath = Path().apply { + addPath(linePath) + lineTo(leftPadding + (weekDays.size - 1) * widthPerPoint, height) + lineTo(leftPadding, height) + close() + } + + drawPath( + path = shadowPath, + brush = Brush.verticalGradient( + colors = listOf(primaryAlpha, primaryColor.copy(alpha = 0f)), + startY = 0f, + endY = height + ) + ) + + // Draw line + drawPath( + path = linePath, + color = primaryColor, + style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round) + ) + + // Draw dots + percentages.forEachIndexed { index, percentage -> + val x = leftPadding + index * widthPerPoint + val y = height - (percentage / 100f * height) + drawCircle( + color = primaryColor, + radius = 6.dp.toPx(), + center = Offset(x, y) + ) + } + + // Draw day labels + dayLabels.forEachIndexed { index, day -> + val x = leftPadding + index * widthPerPoint + val y = height + 24.dp.toPx() + drawContext.canvas.nativeCanvas.apply { + drawText( + day, + x, + y, + android.graphics.Paint().apply { + color = primaryColor.toArgb() + textSize = 12.sp.toPx() + textAlign = android.graphics.Paint.Align.CENTER + } + ) + } + } + } + } + } +} + +@Composable +fun WeeklyAnalytics( + radius: Int, + dayStatus: List +) { + val daysOfWeek = listOf( + stringResource(R.string.monday_short), + stringResource(R.string.tuesday_short), + stringResource(R.string.wednesday_short), + stringResource(R.string.thursday_short), + stringResource(R.string.friday_short), + stringResource(R.string.saturday_short), + stringResource(R.string.sunday_short) + ) + + val completedCount = dayStatus.count { it } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), + onClick = {} + ) { + Column(Modifier.fillMaxSize().padding(12.dp)) { + Text( + text = stringResource(R.string.This_Week), + style = MaterialTheme.typography.titleMedium, + ) + + Text( + text = stringResource(R.string.completed_habits, completedCount), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 24.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + daysOfWeek.forEachIndexed { index, day -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) + ) { + Card( + shape = RoundedCornerShape(50), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = if(dayStatus[index]) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) + ) + ) { + Icon( + imageVector = if (dayStatus[index]) Icons.Default.Verified else Icons.Default.Pending, + contentDescription = null, + modifier = Modifier.padding(4.dp) + ) + } + Text( + text = day, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } + } +} + +@Composable +fun BarChart( + weekCounts: List, + weekLabels: List, + modifier: Modifier = Modifier +) { + val maxDaysPerWeek = 7 + val yLabels = (maxDaysPerWeek downTo 1).toList() + val primaryColor = MaterialTheme.colorScheme.primary + + Row( + modifier = modifier + .fillMaxWidth() + .height(220.dp) + .padding(bottom = 16.dp) + ) { + // Y-axis labels + Box( + modifier = Modifier + .width(20.dp) + .padding(end = 8.dp) + .fillMaxHeight() + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val stepHeight = size.height / maxDaysPerWeek + val textPaint = Paint().asFrameworkPaint().apply { + isAntiAlias = true + textSize = 32f + color = primaryColor.toArgb() + textAlign = android.graphics.Paint.Align.RIGHT + } + + yLabels.forEach { label -> + val y = size.height - (stepHeight * label) + drawContext.canvas.nativeCanvas.drawText( + label.toString(), + size.width - 10f, + y + 5f, + textPaint + ) + } + } + } + + // Bar chart canvas + Canvas( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) { + val barWidth = size.width / (weekCounts.size * 2 + 1) + val stepHeight = size.height / maxDaysPerWeek + + weekCounts.forEachIndexed { index, count -> + val barHeight = stepHeight * count + val x = barWidth + index * (barWidth + barWidth) + val y = size.height - barHeight + + drawRoundRect( + color = primaryColor, + topLeft = Offset(x, y), + size = Size(barWidth, barHeight), + cornerRadius = CornerRadius(x = 16.dp.toPx(), y = 16.dp.toPx()) + ) + + drawIntoCanvas { canvas -> + val paint = Paint().asFrameworkPaint().apply { + isAntiAlias = true + color = primaryColor.toArgb() + textSize = 30f + textAlign = android.graphics.Paint.Align.CENTER + } + + canvas.nativeCanvas.drawText( + weekLabels[index], + x + barWidth / 2, + size.height + 40f, + paint + ) + } + } + + for (i in 1..maxDaysPerWeek) { + val y = size.height - (stepHeight * i) + drawLine( + color = Color.Gray.copy(alpha = 0.5f), + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = 0.25.dp.toPx() + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/analytics/AnalyticsScreen.kt b/app/src/main/java/com/flux/ui/screens/analytics/AnalyticsScreen.kt index 1963ce3..5f39c17 100644 --- a/app/src/main/java/com/flux/ui/screens/analytics/AnalyticsScreen.kt +++ b/app/src/main/java/com/flux/ui/screens/analytics/AnalyticsScreen.kt @@ -14,11 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.filled.Analytics @@ -46,14 +42,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.lerp import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource 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 -import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.flux.R import com.flux.data.model.EventInstanceModel @@ -65,6 +58,7 @@ import com.flux.data.model.WorkspaceModel import com.flux.data.model.isCompleted import com.flux.data.model.occursOn import com.flux.navigation.NavRoutes +import com.flux.ui.common.HeatMapCard import com.flux.ui.common.SpaceTopBar import com.flux.ui.common.SpacesMenu import com.flux.ui.screens.habits.HabitsWeeklyProgressAnalysis @@ -408,7 +402,6 @@ fun HabitHeatMap(radius: Int, habits: List, allHabitInstances: List< }.takeIf { it != -1 } ?: 0 } - val todayHabit = allHabitInstances.count { it.instanceDate == today.toEpochDay() } // Auto-scroll to current month on first composition @@ -430,137 +423,6 @@ fun HabitHeatMap(radius: Int, habits: List, allHabitInstances: List< ) } -@Composable -fun HeatMapCard( - radius: Int, - title: String, - description: String, - boxSize: Dp, - intensityParam: Int, - lazyListState: LazyListState, - weekColumns: List>, - heatMap: Map -){ - Card( - onClick = {}, - modifier = Modifier.fillMaxWidth(), - shape = shapeManager(radius = radius*2), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) - ) - ) { - Column( - Modifier - .fillMaxWidth() - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - title, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - if(description.isNotBlank()){ - Text( - description, - style = MaterialTheme.typography.labelMedium - ) - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp) - ) { - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier - .width(boxSize) - .padding(top = 26.dp, end = 2.dp) - ) { - DayOfWeek.entries.forEach { day -> - Box( - modifier = Modifier - .width(boxSize + 12.dp) - .height(boxSize), - contentAlignment = Alignment.CenterStart - ) { - Text(text = day.name.take(3), fontSize = 9.sp) - } - } - } - - // Combined month + heatmap - LazyRow( - state = lazyListState, - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.fillMaxWidth() - ) { - itemsIndexed(weekColumns) { index, columnDates -> - val firstDate = columnDates.firstOrNull() - val month = firstDate?.month - - // Show month label if this is the first week of the month - val showMonth = - month != null && (index == 0 || weekColumns.getOrNull(index - 1)?.firstOrNull()?.month != month) - - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Month label on top (only once per month) - Box( - modifier = Modifier.height(24.dp), - contentAlignment = Alignment.Center - ) { - if (showMonth) { - Text( - text = firstDate.month.name.take(3), - fontSize = 9.sp, - fontWeight = FontWeight.SemiBold - ) - } - } - - // Heatmap boxes - columnDates.forEach { date -> - if (date != null) { - val count = heatMap[date] ?: 0 - val intensity = - (count / if (intensityParam > 0) intensityParam.toFloat() else 2f) - .coerceIn(0f, 1f) - val color = lerp( - MaterialTheme.colorScheme.primary.copy(alpha = 0.08f), - MaterialTheme.colorScheme.primary, - intensity - ) - Box( - modifier = Modifier - .size(boxSize) - .background(color, RoundedCornerShape(3.dp)), - contentAlignment = Alignment.Center - ) { - Text( - text = date.dayOfMonth.toString(), - fontSize = 9.sp, - color = if (intensity > 0.5f) - MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurface - ) - } - } else { - Box(modifier = Modifier.size(boxSize)) - } - } - } - } - } - } - } - } -} - data class ChartModel( val value: Float, val color: Color, diff --git a/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt b/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt index 4bbc32b..d02c428 100644 --- a/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt +++ b/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt @@ -77,7 +77,6 @@ import com.flux.data.model.isCompleted import com.flux.ui.common.convertMillisToDate import com.flux.ui.common.convertMillisToTime import com.flux.ui.events.HabitEvents -import com.flux.ui.screens.analytics.HeatMapCard import kotlinx.coroutines.flow.distinctUntilChanged import java.time.LocalDate import java.time.temporal.ChronoUnit @@ -85,23 +84,14 @@ import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.automirrored.filled.ArrowBackIos import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos import androidx.compose.material.icons.filled.Analytics -import androidx.compose.material.icons.filled.Pending -import androidx.compose.material.icons.filled.Verified import androidx.compose.material3.IconButton import androidx.compose.runtime.mutableStateOf 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.Paint -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.text.style.TextOverflow import com.flux.data.model.RecurrenceRule import com.flux.ui.screens.events.IconRadioButton @@ -111,7 +101,6 @@ import java.time.YearMonth import java.time.ZoneId import java.time.format.TextStyle import kotlin.collections.filter -import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.grid.GridCells @@ -131,12 +120,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.rememberGraphicsLayer -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import com.flux.data.model.isCounted import com.flux.data.model.isLive @@ -147,12 +133,17 @@ import com.flux.ui.common.SelectionType import com.flux.ui.screens.settings.CircleWrapper import com.flux.ui.screens.settings.shapeManager import androidx.compose.ui.platform.LocalLocale +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import com.flux.data.model.HabitAchievementStats import com.flux.data.model.isActiveOn import com.flux.other.saveBitmapAndGetUri import com.flux.other.shareImageUri +import com.flux.ui.common.BarChart +import com.flux.ui.common.HeatMapCard +import com.flux.ui.common.WeeklyAnalytics +import com.flux.ui.common.WeeklyProgressChart import com.flux.ui.theme.completed import com.flux.ui.theme.flameOrange import com.flux.ui.theme.trophyGold @@ -734,14 +725,16 @@ fun HabitDetailedInfo( ) } - val configuration = LocalConfiguration.current + val windowInfo = LocalWindowInfo.current val density = LocalDensity.current + val screenWidthDp = with(density) { windowInfo.containerSize.width.toDp() } + val columns = when { density.fontScale > 1.5f -> 1 - configuration.screenWidthDp < 360 -> 1 - configuration.screenWidthDp < 480 -> 2 + screenWidthDp < 360.dp -> 1 + screenWidthDp < 480.dp -> 2 else -> 3 } @@ -1485,63 +1478,7 @@ fun WeeklyHabitAnalyticsCard( } } - val completedCount = dayStatus.count { it } - - Card( - modifier = Modifier.fillMaxWidth(), - shape = shapeManager(radius = radius * 2), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - ), - onClick = {} - ) { - Column(Modifier - .fillMaxSize() - .padding(12.dp)) { - Text( - text = stringResource(R.string.This_Week), - style = MaterialTheme.typography.titleMedium, - ) - - Text( - text = stringResource(R.string.completed_habits, completedCount), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 24.dp) - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - daysOfWeek.forEachIndexed { index, day -> - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.weight(1f) - ) { - Card( - shape = RoundedCornerShape(50), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = if(dayStatus[index]) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) - ) - ) { - Icon( - imageVector = if (dayStatus[index]) Icons.Default.Verified else Icons.Default.Pending, - contentDescription = null, - modifier = Modifier.padding(4.dp) - ) - } - Text( - text = day, - style = MaterialTheme.typography.labelLarge, - ) - } - } - } - } - } + WeeklyAnalytics(radius, dayStatus) } @Composable @@ -1629,7 +1566,7 @@ fun MonthlyHabitAnalyticsCard( modifier = Modifier.padding(bottom = 24.dp) ) - HabitBarChart( + BarChart( weekCounts = weekCounts, weekLabels = weekRanges.map { "${it.first}-${it.last}" } ) @@ -1718,236 +1655,6 @@ fun HabitsWeeklyProgressAnalysis( WeeklyProgressChart(radius, modifier, percentages) } -@Composable -fun WeeklyProgressChart( - radius: Int, - modifier: Modifier = Modifier, - percentages: List -){ - val primaryColor = MaterialTheme.colorScheme.primary - val primaryAlpha = primaryColor.copy(alpha = 0.4f) - - val today = LocalDate.now() - val startOfWeek = today.with(DayOfWeek.MONDAY) - val weekDays = (0..6).map { startOfWeek.plusDays(it.toLong()) } - - val dayLabels = listOf( - stringResource(R.string.monday_short), - stringResource(R.string.tuesday_short), - stringResource(R.string.wednesday_short), - stringResource(R.string.thursday_short), - stringResource(R.string.friday_short), - stringResource(R.string.saturday_short), - stringResource(R.string.sunday_short) - ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = shapeManager(radius = radius * 2), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - ), - onClick = {} - ) { - Column(modifier = modifier.padding(12.dp)) { - Text( - stringResource(R.string.weekly_habit_completion), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp, top = 8.dp) - ) - - Canvas( - modifier = Modifier - .fillMaxWidth() - .padding(6.dp) - .height(200.dp) - ) { - val leftPadding = 32.dp.toPx() - val bottomPadding = 32.dp.toPx() - val widthPerPoint = (size.width - leftPadding) / (weekDays.size - 1) - val height = size.height - bottomPadding - - // Vertical percentage labels - for (i in 0..4) { - val y = height - i * height / 4 - drawContext.canvas.nativeCanvas.apply { - drawText( - "${i * 25}%", - leftPadding - 14.dp.toPx(), - y, - android.graphics.Paint().apply { - color = primaryColor.toArgb() - textSize = 12.sp.toPx() - textAlign = android.graphics.Paint.Align.RIGHT - } - ) - } - } - - // Line path - val linePath = Path().apply { - percentages.forEachIndexed { index, percentage -> - val x = leftPadding + index * widthPerPoint - val y = height - (percentage / 100f * height) - if (index == 0) moveTo(x, y) else lineTo(x, y) - } - } - - // Shadow under line - val shadowPath = Path().apply { - addPath(linePath) - lineTo(leftPadding + (weekDays.size - 1) * widthPerPoint, height) - lineTo(leftPadding, height) - close() - } - - drawPath( - path = shadowPath, - brush = Brush.verticalGradient( - colors = listOf(primaryAlpha, primaryColor.copy(alpha = 0f)), - startY = 0f, - endY = height - ) - ) - - // Draw line - drawPath( - path = linePath, - color = primaryColor, - style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round) - ) - - // Draw dots - percentages.forEachIndexed { index, percentage -> - val x = leftPadding + index * widthPerPoint - val y = height - (percentage / 100f * height) - drawCircle( - color = primaryColor, - radius = 6.dp.toPx(), - center = Offset(x, y) - ) - } - - // Draw day labels - dayLabels.forEachIndexed { index, day -> - val x = leftPadding + index * widthPerPoint - val y = height + 24.dp.toPx() - drawContext.canvas.nativeCanvas.apply { - drawText( - day, - x, - y, - android.graphics.Paint().apply { - color = primaryColor.toArgb() - textSize = 12.sp.toPx() - textAlign = android.graphics.Paint.Align.CENTER - } - ) - } - } - } - } - } -} - -@Composable -fun HabitBarChart( - weekCounts: List, - weekLabels: List, - modifier: Modifier = Modifier -) { - val maxDaysPerWeek = 7 - val yLabels = (maxDaysPerWeek downTo 1).toList() - val primaryColor = MaterialTheme.colorScheme.primary - - Row( - modifier = modifier - .fillMaxWidth() - .height(220.dp) - .padding(bottom = 16.dp) - ) { - // Y-axis labels - Box( - modifier = Modifier - .width(20.dp) - .padding(end = 8.dp) - .fillMaxHeight() - ) { - Canvas(modifier = Modifier.fillMaxSize()) { - val stepHeight = size.height / maxDaysPerWeek - val textPaint = Paint().asFrameworkPaint().apply { - isAntiAlias = true - textSize = 32f - color = primaryColor.toArgb() - textAlign = android.graphics.Paint.Align.RIGHT - } - - yLabels.forEach { label -> - val y = size.height - (stepHeight * label) - drawContext.canvas.nativeCanvas.drawText( - label.toString(), - size.width - 10f, - y + 5f, - textPaint - ) - } - } - } - - // Bar chart canvas - Canvas( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - ) { - val barWidth = size.width / (weekCounts.size * 2 + 1) - val stepHeight = size.height / maxDaysPerWeek - - weekCounts.forEachIndexed { index, count -> - val barHeight = stepHeight * count - val x = barWidth + index * (barWidth + barWidth) - val y = size.height - barHeight - - drawRoundRect( - color = primaryColor, - topLeft = Offset(x, y), - size = Size(barWidth, barHeight), - cornerRadius = CornerRadius(x = 16.dp.toPx(), y = 16.dp.toPx()) - ) - - drawIntoCanvas { canvas -> - val paint = Paint().asFrameworkPaint().apply { - isAntiAlias = true - color = primaryColor.toArgb() - textSize = 30f - textAlign = android.graphics.Paint.Align.CENTER - } - - canvas.nativeCanvas.drawText( - weekLabels[index], - x + barWidth / 2, - size.height + 40f, - paint - ) - } - } - - for (i in 1..maxDaysPerWeek) { - val y = size.height - (stepHeight * i) - drawLine( - color = Color.Gray.copy(alpha = 0.5f), - start = Offset(0f, y), - end = Offset(size.width, y), - strokeWidth = 0.25.dp.toPx() - ) - } - } - } -} - data class StreakData( val currentStreak: Int, val bestStreak: Int diff --git a/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt b/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt index 440ef07..e9b8c6b 100644 --- a/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt +++ b/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -45,6 +46,7 @@ import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -59,9 +61,11 @@ import androidx.compose.runtime.remember 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.draw.scale -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -71,24 +75,35 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.navigation.NavController import com.flux.R +import com.flux.data.model.ItemConsistency import com.flux.data.model.RecurrenceRule import com.flux.data.model.TodoInstance import com.flux.data.model.TodoItem import com.flux.data.model.TodoModel +import com.flux.data.model.calculateItemConsistency +import com.flux.data.model.isActiveOn import com.flux.data.model.isCompleted +import com.flux.data.model.startDateAsLocalDate import com.flux.navigation.NavRoutes import com.flux.other.ConvertType +import com.flux.ui.common.BarChart +import com.flux.ui.common.HeatMapCard import com.flux.ui.common.TimePicker +import com.flux.ui.common.WeeklyAnalytics +import com.flux.ui.common.WeeklyProgressChart import com.flux.ui.common.convertMillisToDate import com.flux.ui.common.convertMillisToTime import com.flux.ui.events.TodoEvents -import com.flux.ui.screens.analytics.HeatMapCard import com.flux.ui.screens.events.toFormattedTime import com.flux.ui.screens.habits.HabitInfoComponent import com.flux.ui.screens.notes.ExportCard import com.flux.ui.screens.settings.shapeManager +import java.time.DayOfWeek import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId import java.time.temporal.ChronoUnit +import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -167,7 +182,6 @@ private fun TodoHeaderRow( Icon(Icons.Default.Analytics, null) } } - } } @@ -467,14 +481,16 @@ fun TodoDetailedInfo( ) } - val configuration = LocalConfiguration.current + val windowInfo = LocalWindowInfo.current val density = LocalDensity.current + val screenWidthDp = with(density) { windowInfo.containerSize.width.toDp() } + val columns = when { density.fontScale > 1.5f -> 1 - configuration.screenWidthDp < 360 -> 1 - configuration.screenWidthDp < 480 -> 2 + screenWidthDp < 360.dp -> 1 + screenWidthDp < 480.dp -> 2 else -> 3 } @@ -547,7 +563,6 @@ fun TodoDetailedInfo( ) } } - } } } @@ -651,4 +666,265 @@ fun ConvertTODODialog( } } } +} + +@Composable +fun WeeklyTodoAnalytics( + radius: Int, + instances: List +) { + val today = LocalDate.now() + val startOfWeek = today.with(DayOfWeek.MONDAY) + + val instanceMap = remember(instances) { + instances.associateBy { it.instanceDate } + } + + val daysOfWeek = listOf( + stringResource(R.string.monday_short), + stringResource(R.string.tuesday_short), + stringResource(R.string.wednesday_short), + stringResource(R.string.thursday_short), + stringResource(R.string.friday_short), + stringResource(R.string.saturday_short), + stringResource(R.string.sunday_short) + ) + + val dayStatus = List(daysOfWeek.size) { index -> + val date = startOfWeek.plusDays(index.toLong()) + val epoch = date.toEpochDay() + + val instance = instanceMap[epoch] + + instance?.isCompleted()==true + } + + WeeklyAnalytics(radius, dayStatus) +} + +@Composable +fun TodoWeeklyProgressChart( + radius: Int, + list: TodoModel, + instances: List, + modifier: Modifier = Modifier, + zoneId: ZoneId = ZoneId.systemDefault() +) { + val today = LocalDate.now() + val startOfWeek = today.with(DayOfWeek.MONDAY) + val weekDays = remember(today) { (0..6).map { startOfWeek.plusDays(it.toLong()) } } + val instanceMap = remember(instances) { instances.associateBy { it.instanceDate } } + val startDate = remember(list.startDateTime, zoneId) { list.startDateAsLocalDate(zoneId) } + + val percentages = weekDays.map { day -> + val isScheduled = list.recurrence.isActiveOn(day, startDate) + if (!isScheduled) return@map 0f + + val instance = instanceMap[day.toEpochDay()] + if (instance == null || instance.items.isEmpty()) return@map 0f + + val completed = instance.items.count { it.isChecked } + val total = instance.items.size + (completed.toFloat() / total) * 100f + } + + WeeklyProgressChart( + radius = radius, + modifier = modifier, + percentages = percentages + ) +} + +@Composable +fun MonthlyHabitAnalyticsCard( + radius: Int, + list: TodoModel, + instances: List +) { + val today = LocalDate.now() + val currentYearMonth = YearMonth.of(today.year, today.month) + val daysInMonth = currentYearMonth.lengthOfMonth() + + val instanceMap = remember(instances) { + instances.associateBy { it.instanceDate } + } + + val weekRanges = remember(daysInMonth) { + val ranges = mutableListOf() + var start = 1 + while (start <= daysInMonth) { + val end = minOf(start + 6, daysInMonth) + ranges.add(start..end) + start = end + 1 + } + ranges + } + + val weekCounts = remember(instances, list) { + val counts = MutableList(weekRanges.size) { 0 } + + for (day in 1..daysInMonth) { + val date = currentYearMonth.atDay(day) + val epoch = date.toEpochDay() + val instance = instanceMap[epoch] + val isCompleted = instance?.isCompleted() == true + + if (isCompleted) { + weekRanges.forEachIndexed { index, range -> + if (day in range) { + counts[index]++ + return@forEachIndexed + } + } + } + } + + counts + } + + val completedDays = weekCounts.sum() + + Card( + modifier = Modifier.fillMaxWidth(), + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ), + onClick = {} + ) { + Column( + Modifier + .fillMaxSize() + .padding(12.dp) + ) { + Text( + text = stringResource(R.string.This_Month), + style = MaterialTheme.typography.titleMedium, + ) + + Text( + text = stringResource(R.string.completed_habits, completedDays), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 24.dp) + ) + + BarChart( + weekCounts = weekCounts, + weekLabels = weekRanges.map { "${it.first}-${it.last}" } + ) + } + } +} + +@Composable +fun ItemConsistencyCard( + radius: Int, + list: TodoModel, + instances: List, + modifier: Modifier = Modifier +) { + val consistencyList = remember(list.items, instances) { + list.calculateItemConsistency(instances) + } + val (activeItems, removedItems) = remember(consistencyList) { + consistencyList.partition { !it.isRemoved } + } + val primaryColor = MaterialTheme.colorScheme.primary + + Card( + modifier = Modifier.fillMaxWidth(), + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ), + onClick = {} + ) { + Column(modifier = modifier.padding(12.dp)) { + Text( + "Item Consistency", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp, top = 8.dp) + ) + + if (consistencyList.isEmpty()) { + Text( + "No Data Yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp) + ) + } else { + activeItems.forEach { item -> + ConsistencyRow(item = item, primaryColor = primaryColor) + } + + if (removedItems.isNotEmpty()) { + Text( + "Removed Items", + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 4.dp) + ) + removedItems.forEach { item -> + ConsistencyRow( + item = item, + primaryColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + +@Composable +private fun ConsistencyRow(item: ItemConsistency, primaryColor: Color) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = item.label, + style = MaterialTheme.typography.bodyMedium, + color = if (item.isRemoved) + MaterialTheme.colorScheme.onSurfaceVariant + else + MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.width(8.dp)) + + LinearProgressIndicator( + progress = { item.percentage }, + modifier = Modifier + .weight(1f) + .height(6.dp) + .clip(RoundedCornerShape(50)), + color = primaryColor, + trackColor = primaryColor.copy(alpha = 0.15f) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = "${(item.percentage * 100).roundToInt()}%", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.widthIn(min = 36.dp), + textAlign = TextAlign.End + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/todo/TodoDetail.kt b/app/src/main/java/com/flux/ui/screens/todo/TodoDetail.kt index 8d346da..a172101 100644 --- a/app/src/main/java/com/flux/ui/screens/todo/TodoDetail.kt +++ b/app/src/main/java/com/flux/ui/screens/todo/TodoDetail.kt @@ -183,7 +183,6 @@ fun TodoDetail( else if(isAllowed(todayEpoch) && todayInstance!=null) { item{ TodoDetailedInfo(radius, list, isReminderOn = true, isAllowedToday = true, todayInstance) } - item{ TodoHeatMap(radius, list, instances) } items(todayInstance.items) { todoItem -> MaterialListItem(true, todoItem){ @@ -197,6 +196,12 @@ fun TodoDetail( } } } + + item { ItemConsistencyCard(radius, list, instances) } + item { WeeklyTodoAnalytics(radius, instances) } + item { TodoWeeklyProgressChart(radius, list, instances) } + item { MonthlyHabitAnalyticsCard(radius, list, instances) } + item{ TodoHeatMap(radius, list, instances) } } else if(!isAllowed(todayEpoch)){ @@ -206,11 +211,14 @@ fun TodoDetail( isAllowedToday = false, todayInstance = todayInstance) } - item{ TodoHeatMap(radius, list, instances) } - items(list.items) { todoItem -> MaterialListItem(false, todoItem){} } + item { ItemConsistencyCard(radius, list, instances) } + item { WeeklyTodoAnalytics(radius, instances) } + item { TodoWeeklyProgressChart(radius, list, instances) } + item { MonthlyHabitAnalyticsCard(radius, list, instances) } + item{ TodoHeatMap(radius, list, instances) } } } } From 82738d732996bea32f3eff9babc153d259138b48 Mon Sep 17 00:00:00 2001 From: chindaronit Date: Sun, 19 Jul 2026 14:19:24 +0530 Subject: [PATCH 2/2] feat: - Calendar analytics card for todo list. - journal auto selected for old and new entries in date picker - search screen preserving category while navigation res: multilingualism for added strings. --- app/build.gradle.kts | 1 + .../main/java/com/flux/ui/common/Charts.kt | 30 +- .../main/java/com/flux/ui/common/Dialog.kt | 5 +- .../flux/ui/screens/habits/HabitComponents.kt | 201 +++++++------ .../flux/ui/screens/journal/EditJournal.kt | 16 +- .../progressBoard/ProgressBoardComponents.kt | 2 +- .../flux/ui/screens/search/SearchScreen.kt | 22 +- .../flux/ui/screens/todo/TodoComponents.kt | 267 +++++++++++++++++- .../com/flux/ui/screens/todo/TodoDetail.kt | 40 +-- .../screens/workspaces/NewWorkspaceScreen.kt | 7 +- app/src/main/res/values-de-rDE/strings.xml | 11 + app/src/main/res/values-es-rES/strings.xml | 11 + app/src/main/res/values-fr-rFR/strings.xml | 11 + app/src/main/res/values-hi-rIN/strings.xml | 12 + app/src/main/res/values-nl-rNL/strings.xml | 11 + app/src/main/res/values-pt-rBR/strings.xml | 11 + app/src/main/res/values-ru-rRU/strings.xml | 12 + app/src/main/res/values-zh-rCN/strings.xml | 11 + app/src/main/res/values/strings.xml | 13 +- gradle/libs.versions.toml | 2 + 20 files changed, 501 insertions(+), 195 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8eef78f..932c83a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,6 +86,7 @@ kotlin { } dependencies { + implementation(libs.androidx.compose.ui.text) implementation(libs.androidx.core.ktx) implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.material3) diff --git a/app/src/main/java/com/flux/ui/common/Charts.kt b/app/src/main/java/com/flux/ui/common/Charts.kt index de7ea1e..00055a1 100644 --- a/app/src/main/java/com/flux/ui/common/Charts.kt +++ b/app/src/main/java/com/flux/ui/common/Charts.kt @@ -1,5 +1,6 @@ package com.flux.ui.common +import android.graphics.Paint import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -34,7 +35,6 @@ 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.Paint import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke @@ -80,9 +80,9 @@ fun HeatMapCard( ) { Text( title, + textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) if(description.isNotBlank()){ @@ -213,13 +213,11 @@ fun WeeklyProgressChart( ) { Column(modifier = modifier.padding(12.dp)) { Text( - stringResource(R.string.weekly_habit_completion), + stringResource(R.string.This_Week), + textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp, top = 8.dp) + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) ) Canvas( @@ -241,10 +239,10 @@ fun WeeklyProgressChart( "${i * 25}%", leftPadding - 14.dp.toPx(), y, - android.graphics.Paint().apply { + Paint().apply { color = primaryColor.toArgb() textSize = 12.sp.toPx() - textAlign = android.graphics.Paint.Align.RIGHT + textAlign = Paint.Align.RIGHT } ) } @@ -303,10 +301,10 @@ fun WeeklyProgressChart( day, x, y, - android.graphics.Paint().apply { + Paint().apply { color = primaryColor.toArgb() textSize = 12.sp.toPx() - textAlign = android.graphics.Paint.Align.CENTER + textAlign = Paint.Align.CENTER } ) } @@ -411,11 +409,11 @@ fun BarChart( ) { Canvas(modifier = Modifier.fillMaxSize()) { val stepHeight = size.height / maxDaysPerWeek - val textPaint = Paint().asFrameworkPaint().apply { + val textPaint = Paint().apply { isAntiAlias = true - textSize = 32f color = primaryColor.toArgb() - textAlign = android.graphics.Paint.Align.RIGHT + textSize = 30f + textAlign = Paint.Align.CENTER } yLabels.forEach { label -> @@ -452,11 +450,11 @@ fun BarChart( ) drawIntoCanvas { canvas -> - val paint = Paint().asFrameworkPaint().apply { + val paint = Paint().apply { isAntiAlias = true color = primaryColor.toArgb() textSize = 30f - textAlign = android.graphics.Paint.Align.CENTER + textAlign = Paint.Align.CENTER } canvas.nativeCanvas.drawText( diff --git a/app/src/main/java/com/flux/ui/common/Dialog.kt b/app/src/main/java/com/flux/ui/common/Dialog.kt index e734346..71f1608 100644 --- a/app/src/main/java/com/flux/ui/common/Dialog.kt +++ b/app/src/main/java/com/flux/ui/common/Dialog.kt @@ -78,10 +78,13 @@ fun convertMillisToDate(millis: Long): String { @OptIn(ExperimentalMaterial3Api::class) @Composable fun DatePickerModal( + initialSelectedDateMillis: Long = System.currentTimeMillis(), onDateSelected: (Long?) -> Unit, onDismiss: () -> Unit ) { - val datePickerState = rememberDatePickerState() + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = initialSelectedDateMillis + ) DatePickerDialog( onDismissRequest = onDismiss, diff --git a/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt b/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt index d02c428..f391e9f 100644 --- a/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt +++ b/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt @@ -127,9 +127,6 @@ import androidx.compose.ui.platform.LocalContext import com.flux.data.model.isCounted import com.flux.data.model.isLive import com.flux.data.model.isTimed -import com.flux.ui.common.FilterCategory -import com.flux.ui.common.FilterOption -import com.flux.ui.common.SelectionType import com.flux.ui.screens.settings.CircleWrapper import com.flux.ui.screens.settings.shapeManager import androidx.compose.ui.platform.LocalLocale @@ -615,69 +612,69 @@ fun NumberPicker( } } -@Composable -fun TimedHabitDialog( - durationMillis: Long, - onDismiss: () -> Unit, - onConfirm: (Long) -> Unit -){ - val totalMinutes = durationMillis / 60_000L - var hours by remember { mutableIntStateOf((totalMinutes / 60).toInt()) } - var minutes by remember { mutableIntStateOf((totalMinutes % 60).toInt()) } - - // Prevent invalid state - LaunchedEffect(hours, minutes) { - if (hours == 0 && minutes == 0) { - minutes = 1 - } - } - - var selectedIndex by remember { mutableIntStateOf(0) } - val options = listOf("Countdown", "Manual") - val icons = listOf(Icons.Default.HourglassTop, Icons.Default.Edit) - - Dialog(onDismissRequest = onDismiss) { - Surface(shape = RoundedCornerShape(24.dp)) { - Column(Modifier - .padding(16.dp) - .fillMaxWidth()) { - SingleChoiceSegmentedButtonRow(modifier = Modifier.padding(vertical = 6.dp)) { - options.forEachIndexed { index, label -> - SegmentedButton( - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = options.size - ), - onClick = { selectedIndex = index }, - selected = index == selectedIndex, - label = { Text(label) }, - icon = { Icon(icons[index], null) } - ) - } - } - - NumberPickerRow( - Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - hours, - minutes, - { hours=it } - ) { minutes=it } - Spacer(Modifier.height(16.dp)) - FilledTonalButton( - onClick = { - onDismiss() - onConfirm(toMillis(hours, minutes)) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.Confirm)) - } - } - } - } -} +//@Composable +//fun TimedHabitDialog( +// durationMillis: Long, +// onDismiss: () -> Unit, +// onConfirm: (Long) -> Unit +//){ +// val totalMinutes = durationMillis / 60_000L +// var hours by remember { mutableIntStateOf((totalMinutes / 60).toInt()) } +// var minutes by remember { mutableIntStateOf((totalMinutes % 60).toInt()) } +// +// // Prevent invalid state +// LaunchedEffect(hours, minutes) { +// if (hours == 0 && minutes == 0) { +// minutes = 1 +// } +// } +// +// var selectedIndex by remember { mutableIntStateOf(0) } +// val options = listOf("Countdown", "Manual") +// val icons = listOf(Icons.Default.HourglassTop, Icons.Default.Edit) +// +// Dialog(onDismissRequest = onDismiss) { +// Surface(shape = RoundedCornerShape(24.dp)) { +// Column(Modifier +// .padding(16.dp) +// .fillMaxWidth()) { +// SingleChoiceSegmentedButtonRow(modifier = Modifier.padding(vertical = 6.dp)) { +// options.forEachIndexed { index, label -> +// SegmentedButton( +// shape = SegmentedButtonDefaults.itemShape( +// index = index, +// count = options.size +// ), +// onClick = { selectedIndex = index }, +// selected = index == selectedIndex, +// label = { Text(label) }, +// icon = { Icon(icons[index], null) } +// ) +// } +// } +// +// NumberPickerRow( +// Modifier +// .fillMaxWidth() +// .padding(horizontal = 32.dp), +// hours, +// minutes, +// { hours=it } +// ) { minutes=it } +// Spacer(Modifier.height(16.dp)) +// FilledTonalButton( +// onClick = { +// onDismiss() +// onConfirm(toMillis(hours, minutes)) +// }, +// modifier = Modifier.fillMaxWidth() +// ) { +// Text(stringResource(R.string.Confirm)) +// } +// } +// } +// } +//} @Composable fun HabitDetailedInfo( @@ -1889,41 +1886,41 @@ fun HabitConfigCard( } } -fun buildFilterCategories(recurrence: RecurrenceRule): List { - return listOf( - FilterCategory( - name = "Sort By", - options = listOf( - FilterOption("latest", "Latest"), - FilterOption("oldest", "Oldest") - ), - type = SelectionType.SINGLE - ), - FilterCategory( - name = "Recurrence", - options = listOf( - FilterOption("recurrence", "Occurs On", null, recurrence) - ), - type = SelectionType.RECURRENCE - ), - FilterCategory( - name = "Type", - options = listOf( - FilterOption("simple", "Simple"), - FilterOption("timed", "Timed") - ), - type = SelectionType.SINGLE - ), - FilterCategory( - name = "Active", - options = listOf( - FilterOption("live", "Live"), - FilterOption("ended", "Ended") - ), - type = SelectionType.SINGLE - ) - ) -} +//fun buildFilterCategories(recurrence: RecurrenceRule): List { +// return listOf( +// FilterCategory( +// name = "Sort By", +// options = listOf( +// FilterOption("latest", "Latest"), +// FilterOption("oldest", "Oldest") +// ), +// type = SelectionType.SINGLE +// ), +// FilterCategory( +// name = "Recurrence", +// options = listOf( +// FilterOption("recurrence", "Occurs On", null, recurrence) +// ), +// type = SelectionType.RECURRENCE +// ), +// FilterCategory( +// name = "Type", +// options = listOf( +// FilterOption("simple", "Simple"), +// FilterOption("timed", "Timed") +// ), +// type = SelectionType.SINGLE +// ), +// FilterCategory( +// name = "Active", +// options = listOf( +// FilterOption("live", "Live"), +// FilterOption("ended", "Ended") +// ), +// type = SelectionType.SINGLE +// ) +// ) +//} @Composable fun AchievementCard( @@ -2125,7 +2122,7 @@ fun AchievementCard( verticalArrangement = Arrangement.Top ) { Text( - stringResource(R.string.current_streak), + stringResource(R.string.current_streak_text), style = MaterialTheme.typography.labelSmall, modifier = Modifier.alpha(0.75f) ) diff --git a/app/src/main/java/com/flux/ui/screens/journal/EditJournal.kt b/app/src/main/java/com/flux/ui/screens/journal/EditJournal.kt index 844bea8..81311e3 100644 --- a/app/src/main/java/com/flux/ui/screens/journal/EditJournal.kt +++ b/app/src/main/java/com/flux/ui/screens/journal/EditJournal.kt @@ -444,6 +444,7 @@ fun EditJournal( if(showDateDialog){ DatePickerModal( + initialSelectedDateMillis = journalDate, onDateSelected = { selectedDate -> if (selectedDate == null) return@DatePickerModal @@ -457,7 +458,6 @@ fun EditJournal( val today = LocalDate.now() when { - // future date not allowed selectedLocalDate.isAfter(today) -> { Toast.makeText( context, @@ -466,31 +466,27 @@ fun EditJournal( ).show() } - // today -> use current exact time selectedLocalDate.isEqual(today) -> { journalDate = now } - // past date -> keep selected date but current time else -> { - val currentTime = Instant.ofEpochMilli(now) .atZone(ZoneId.systemDefault()) .toLocalTime() - val finalMillis = selectedLocalDate + journalDate = selectedLocalDate .atTime(currentTime) .atZone(ZoneId.systemDefault()) .toInstant() .toEpochMilli() - - journalDate = finalMillis } } + }, + onDismiss = { + showDateDialog = false } - ) { - showDateDialog = false - } + ) } if (showLinkDialog) { diff --git a/app/src/main/java/com/flux/ui/screens/progressBoard/ProgressBoardComponents.kt b/app/src/main/java/com/flux/ui/screens/progressBoard/ProgressBoardComponents.kt index 1966800..724d8cc 100644 --- a/app/src/main/java/com/flux/ui/screens/progressBoard/ProgressBoardComponents.kt +++ b/app/src/main/java/com/flux/ui/screens/progressBoard/ProgressBoardComponents.kt @@ -106,7 +106,7 @@ fun NewBoardItemSheet( if (showDateSelector) { DatePickerModal( - { + onDateSelected = { val selectedDate = it ?: -1L if (isSelectingStartDate) { diff --git a/app/src/main/java/com/flux/ui/screens/search/SearchScreen.kt b/app/src/main/java/com/flux/ui/screens/search/SearchScreen.kt index 27af112..e61b881 100644 --- a/app/src/main/java/com/flux/ui/screens/search/SearchScreen.kt +++ b/app/src/main/java/com/flux/ui/screens/search/SearchScreen.kt @@ -54,8 +54,9 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController @@ -112,7 +113,6 @@ import java.time.LocalDate import java.time.YearMonth import kotlin.collections.minus import kotlin.collections.plus -import kotlin.math.absoluteValue @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -232,7 +232,7 @@ fun SearchScreen(navController: NavController, states: States, viewModels: ViewM val inProgressItems = boardItems.filter { it.status == 1 } val completedItems = boardItems.filter { it.status == 2 } var selectedProgressBoardItem by remember { mutableStateOf(null) } - var selectedSpace by remember { mutableIntStateOf(1) } + val selectedSpace = rememberSaveable { mutableIntStateOf(1) } val workspacesLabel = stringResource(R.string.workspaces) val spacesLabel = stringResource(R.string.spaces) @@ -245,8 +245,8 @@ fun SearchScreen(navController: NavController, states: States, viewModels: ViewM LaunchedEffect(selectedSpaces) { val availableIds = selectedSpaces.filter { it.id != 6 }.map { it.id } - if (availableIds.isNotEmpty() && selectedSpace !in availableIds) { - selectedSpace = availableIds.first() + if (availableIds.isNotEmpty() && selectedSpace.intValue !in availableIds) { + selectedSpace.intValue = availableIds.first() } } @@ -284,12 +284,12 @@ fun SearchScreen(navController: NavController, states: States, viewModels: ViewM ) { items(selectedSpaces){ space-> FilterChip( - onClick = { selectedSpace = space.id }, + onClick = { selectedSpace.intValue = space.id }, label = { Text(space.title) }, - selected = selectedSpace == space.id, + selected = selectedSpace.intValue == space.id, leadingIcon = { Icon( - imageVector = if (selectedSpace == space.id) Icons.Filled.Done else space.icon, + imageVector = if (selectedSpace.intValue == space.id) Icons.Filled.Done else space.icon, contentDescription = "Done icon", modifier = Modifier.size(FilterChipDefaults.IconSize) ) @@ -299,7 +299,7 @@ fun SearchScreen(navController: NavController, states: States, viewModels: ViewM } } - when(selectedSpace.absoluteValue) { + when(selectedSpace.intValue) { 1 -> searchedNotes(navController, notesPreviewMode, radius, notes, labels) 2 -> searchedTodo(navController, radius, context, expandedTODOIds.value, todoLists, { id-> val todoItem = todoLists.first { it.id == id } @@ -627,7 +627,9 @@ fun FilterSheet( onApply: (Map>) -> Unit ) { val context = LocalContext.current - val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.4f + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val maxHeight = with(density) { windowInfo.containerSize.height.toDp() } * 0.4f val categories = remember(workspaces, spaces) { buildSearchFilterCategories(context, workspaces, spaces) } diff --git a/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt b/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt index e9b8c6b..327158e 100644 --- a/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt +++ b/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt @@ -1,6 +1,8 @@ package com.flux.ui.screens.todo import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,15 +15,19 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBackIos +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos import androidx.compose.material.icons.automirrored.outlined.Note import androidx.compose.material.icons.filled.Alarm import androidx.compose.material.icons.filled.AlarmAdd @@ -61,10 +67,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -81,9 +90,7 @@ import com.flux.data.model.TodoInstance import com.flux.data.model.TodoItem import com.flux.data.model.TodoModel import com.flux.data.model.calculateItemConsistency -import com.flux.data.model.isActiveOn import com.flux.data.model.isCompleted -import com.flux.data.model.startDateAsLocalDate import com.flux.navigation.NavRoutes import com.flux.other.ConvertType import com.flux.ui.common.BarChart @@ -96,12 +103,16 @@ import com.flux.ui.common.convertMillisToTime import com.flux.ui.events.TodoEvents import com.flux.ui.screens.events.toFormattedTime import com.flux.ui.screens.habits.HabitInfoComponent +import com.flux.ui.screens.habits.isDateAllowedForHabit import com.flux.ui.screens.notes.ExportCard import com.flux.ui.screens.settings.shapeManager import java.time.DayOfWeek +import java.time.Instant import java.time.LocalDate import java.time.YearMonth import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle import java.time.temporal.ChronoUnit import kotlin.math.roundToInt @@ -239,15 +250,15 @@ fun MaterialListItem( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(50), colors = CardDefaults.cardColors( - containerColor = if (todoItem.isChecked) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainerHighest + containerColor = if (todoItem.isChecked) MaterialTheme.colorScheme.primaryContainer.copy(0.5f) else MaterialTheme.colorScheme.primaryContainer.copy(0.7f) ), onClick = onToggleCheck ) { Row (verticalAlignment = Alignment.CenterVertically) { IconButton( onToggleCheck, colors = IconButtonDefaults.iconButtonColors( - containerColor = if (todoItem.isChecked) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = if (todoItem.isChecked) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer ) ) { if (enabled) { Icon(if (todoItem.isChecked) Icons.Default.Verified else Icons.Outlined.Circle, null) } @@ -410,7 +421,7 @@ fun TodoReminderDialog( } else { Spacer(modifier = Modifier.height(4.dp)) - Text("Turn on the reminder to remind about this to-do list!", modifier = Modifier + Text(stringResource(R.string.turn_on_reminder), modifier = Modifier .fillMaxWidth() .align(Alignment.CenterHorizontally)) Spacer(modifier = Modifier.height(4.dp)) @@ -705,20 +716,15 @@ fun WeeklyTodoAnalytics( @Composable fun TodoWeeklyProgressChart( radius: Int, - list: TodoModel, instances: List, modifier: Modifier = Modifier, - zoneId: ZoneId = ZoneId.systemDefault() ) { val today = LocalDate.now() val startOfWeek = today.with(DayOfWeek.MONDAY) val weekDays = remember(today) { (0..6).map { startOfWeek.plusDays(it.toLong()) } } val instanceMap = remember(instances) { instances.associateBy { it.instanceDate } } - val startDate = remember(list.startDateTime, zoneId) { list.startDateAsLocalDate(zoneId) } val percentages = weekDays.map { day -> - val isScheduled = list.recurrence.isActiveOn(day, startDate) - if (!isScheduled) return@map 0f val instance = instanceMap[day.toEpochDay()] if (instance == null || instance.items.isEmpty()) return@map 0f @@ -842,7 +848,7 @@ fun ItemConsistencyCard( ) { Column(modifier = modifier.padding(12.dp)) { Text( - "Item Consistency", + stringResource(R.string.item_consistency), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center, @@ -853,7 +859,7 @@ fun ItemConsistencyCard( if (consistencyList.isEmpty()) { Text( - "No Data Yet", + stringResource(R.string.no_data_yet), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -866,7 +872,7 @@ fun ItemConsistencyCard( if (removedItems.isNotEmpty()) { Text( - "Removed Items", + stringResource(R.string.removed_items), style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier @@ -927,4 +933,235 @@ private fun ConsistencyRow(item: ItemConsistency, primaryColor: Color) { textAlign = TextAlign.End ) } -} \ No newline at end of file +} + +@Composable +fun TodoCalendarCard( + radius: Int, + list: TodoModel, + instances: List, + onTodoEvents: (TodoEvents) -> Unit +) { + val context = LocalContext.current + val startDateTime = list.startDateTime + val recurrence = list.recurrence + + val todoStartMonth = + Instant.ofEpochMilli(startDateTime).atZone(ZoneId.systemDefault()).toLocalDate() + .let { YearMonth.of(it.year, it.month) } + + var currentMonth by remember { mutableStateOf(YearMonth.now()) } + val currentYearMonth = YearMonth.now() + val endOfYear = YearMonth.of(currentYearMonth.year, 12) + + val canGoBack = currentMonth > todoStartMonth + val canGoForward = currentMonth < endOfYear + + val daysInMonth = currentMonth.lengthOfMonth() + val firstDayOfWeek = currentMonth.atDay(1).dayOfWeek.value % 7 + val dates = (1..daysInMonth).map { currentMonth.atDay(it) } + + val daysOfWeek = listOf( + stringResource(R.string.sunday_short), + stringResource(R.string.monday_short), + stringResource(R.string.tuesday_short), + stringResource(R.string.wednesday_short), + stringResource(R.string.thursday_short), + stringResource(R.string.friday_short), + stringResource(R.string.saturday_short) + ) + val today = LocalDate.now() + val todoStartDate = Instant.ofEpochMilli(startDateTime).atZone(ZoneId.systemDefault()).toLocalDate() + val locale = LocalLocale.current.platformLocale + var selectedDate by remember { mutableStateOf(today) } + val selectedInstance = remember(selectedDate, instances) { + instances.find { it.instanceDate == selectedDate.toEpochDay() } + } + + Card( + modifier = Modifier.fillMaxWidth(), + onClick = {}, + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ) + ) { + Column(Modifier.padding(16.dp)) { + // Month navigation + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = currentMonth.month.getDisplayName(TextStyle.FULL, locale) + + " ${currentMonth.year}", + style = MaterialTheme.typography.titleMedium + ) + Row { + IconButton( + onClick = { if (canGoBack) currentMonth = currentMonth.minusMonths(1) }, + enabled = canGoBack + ) { + Icon( + Icons.AutoMirrored.Default.ArrowBackIos, + contentDescription = "Previous Month", + modifier = Modifier + .alpha(if (canGoBack) 0.8f else 0.3f) + .size(16.dp) + ) + } + IconButton( + onClick = { if (canGoForward) currentMonth = currentMonth.plusMonths(1) }, + enabled = canGoForward + ) { + Icon( + Icons.AutoMirrored.Default.ArrowForwardIos, + contentDescription = "Next Month", + modifier = Modifier + .alpha(if (canGoForward) 0.8f else 0.3f) + .size(16.dp) + ) + } + } + } + + // Days of week row + Row(Modifier.fillMaxWidth().padding(bottom = 6.dp), horizontalArrangement = Arrangement.SpaceBetween) { + daysOfWeek.forEach { + Text( + text = it, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold) + ) + } + } + + // Calendar grid + LazyVerticalGrid( + columns = GridCells.Fixed(7), + userScrollEnabled = false, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().heightIn(max=330.dp) + ) { + // Empty cells before 1st day + items(firstDayOfWeek) { Box(modifier = Modifier.size(32.dp)) } + + items(dates) { date -> + val epochDay = date.toEpochDay() + val instance = instances.find { it.instanceDate == epochDay } + val isMarked = instance?.isCompleted() ?: false + val isBeforeStart = epochDay < todoStartDate.toEpochDay() + val isAfterToday = epochDay > today.toEpochDay() + val isAllowedByRecurrence = isDateAllowedForHabit(recurrence, epochDay) + + val backgroundColor = when { + isMarked -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + isAllowedByRecurrence && !isBeforeStart -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + else -> Color.Transparent + } + + val textColor = when { + isMarked -> MaterialTheme.colorScheme.onPrimary + isAllowedByRecurrence && !isBeforeStart -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + } + + val dateAlpha = when { + isBeforeStart -> 0.2f + isAfterToday -> 0.4f + !isAllowedByRecurrence -> 0.4f + else -> 1f + } + + val cantMarkFutureDate = stringResource(R.string.cannot_mark_future_dates) + val habitStartsLater = stringResource(R.string.tracking_starts_later) + val notInSchedule = stringResource(R.string.date_not_in_schedule) + + Box( + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .alpha(dateAlpha) + .clickable { + when { + isAfterToday -> Toast.makeText( + context, + cantMarkFutureDate, + Toast.LENGTH_SHORT + ).show() + + isBeforeStart -> Toast.makeText( + context, + habitStartsLater, + Toast.LENGTH_SHORT + ).show() + + !isAllowedByRecurrence -> { + selectedDate=date + Toast.makeText( + context, + notInSchedule, + Toast.LENGTH_SHORT + ).show() + } + + else -> { + selectedDate = date + } + } + }, + contentAlignment = Alignment.Center + ) { + Text(date.dayOfMonth.toString(), color = textColor) + } + } + } + + Row(Modifier.fillMaxWidth().padding(vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Checklist, null, tint = MaterialTheme.colorScheme.primary) + Text(stringResource(R.string.list_items), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.weight(1f)) + Text(DateTimeFormatter.ofPattern("d MMM").format(selectedDate), style = MaterialTheme.typography.bodyMedium) + } + + LazyColumn( + Modifier.fillMaxWidth().heightIn(max = 400.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (selectedInstance == null) { + item { + Text( + stringResource(R.string.no_checklist_for_date), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + items(selectedInstance.items) { todoItem -> + MaterialListItem(true, todoItem) { + val updatedItems = selectedInstance.items.map { + if (it.id == todoItem.id) + it.copy(isChecked = !it.isChecked) + else + it + } + + onTodoEvents( + TodoEvents.UpsertInstance( + selectedInstance.copy(items = updatedItems) + ) + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/flux/ui/screens/todo/TodoDetail.kt b/app/src/main/java/com/flux/ui/screens/todo/TodoDetail.kt index a172101..ca62eed 100644 --- a/app/src/main/java/com/flux/ui/screens/todo/TodoDetail.kt +++ b/app/src/main/java/com/flux/ui/screens/todo/TodoDetail.kt @@ -181,44 +181,14 @@ fun TodoDetail( } } - else if(isAllowed(todayEpoch) && todayInstance!=null) { - item{ TodoDetailedInfo(radius, list, isReminderOn = true, isAllowedToday = true, todayInstance) } - - items(todayInstance.items) { todoItem -> - MaterialListItem(true, todoItem){ - val updatedItems = todayInstance.items.map { - if (it.id == todoItem.id) it.copy(isChecked = !it.isChecked) - else it - } - - if (updatedItems != todayInstance.items) { - onTodoEvents(TodoEvents.UpsertInstance(todayInstance.copy(items = updatedItems))) - } - } - } - - item { ItemConsistencyCard(radius, list, instances) } - item { WeeklyTodoAnalytics(radius, instances) } - item { TodoWeeklyProgressChart(radius, list, instances) } - item { MonthlyHabitAnalyticsCard(radius, list, instances) } - item{ TodoHeatMap(radius, list, instances) } - } - - else if(!isAllowed(todayEpoch)){ - item{ - TodoDetailedInfo(radius, list, - isReminderOn = true, - isAllowedToday = false, - todayInstance = todayInstance) - } - items(list.items) { todoItem -> - MaterialListItem(false, todoItem){} - } + else { + item { TodoDetailedInfo(radius, list, isReminderOn = true, isAllowedToday = isAllowed(todayEpoch), todayInstance) } + item { TodoCalendarCard(radius, list, instances, onTodoEvents) } item { ItemConsistencyCard(radius, list, instances) } item { WeeklyTodoAnalytics(radius, instances) } - item { TodoWeeklyProgressChart(radius, list, instances) } + item { TodoWeeklyProgressChart(radius, instances) } item { MonthlyHabitAnalyticsCard(radius, list, instances) } - item{ TodoHeatMap(radius, list, instances) } + item { TodoHeatMap(radius, list, instances) } } } } diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/NewWorkspaceScreen.kt b/app/src/main/java/com/flux/ui/screens/workspaces/NewWorkspaceScreen.kt index a55f9e2..38ff1ef 100644 --- a/app/src/main/java/com/flux/ui/screens/workspaces/NewWorkspaceScreen.kt +++ b/app/src/main/java/com/flux/ui/screens/workspaces/NewWorkspaceScreen.kt @@ -132,9 +132,8 @@ fun NewWorkspaceScreen( navController.popBackStack() }, onDismissRequest = { showSpaceDeleteWarningDialog = false }, - dialogTitle = "Delete Alert!", - dialogText = "You are about to delete ${removedSpaces.joinToString(", ") { it.title }} " + - "space's with their data permanently, this can't be undone." + dialogTitle = stringResource(R.string.delete_alert), + dialogText = stringResource(R.string.delete_spaces_alert, removedSpaces.joinToString { it.title }) ) } @@ -253,7 +252,7 @@ fun NewWorkspaceScreen( if (canDrag) { Text( - "Drag horizontally to reorder", + stringResource(R.string.drag_horizontally_to_reorder), style = MaterialTheme.typography.bodyMedium ) Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index c2a4d16..83c6bda 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -529,4 +529,15 @@ Verpasst Todo auswählen + Listenelemente + Für dieses Datum gibt es keine Checkliste. + Die Nachverfolgung beginnt später! + Entfernte Elemente + Noch keine Daten + Element-Konsistenz + Aktuelle Serie + Zum Neuordnen horizontal ziehen + Löschwarnung! + Du bist dabei, %1$s Bereich(e) mit allen Daten dauerhaft zu löschen. Dies kann nicht rückgängig gemacht werden. + \ No newline at end of file diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 7b10f8e..5f80dec 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -533,4 +533,15 @@ Perdido Elegir una tarea + Elementos de la lista + No hay una lista de verificación para esta fecha. + ¡El seguimiento comienza más tarde! + Elementos eliminados + Aún no hay datos + Consistencia de los elementos + Racha actual + Arrastra horizontalmente para reordenar + ¡Alerta de eliminación! + Estás a punto de eliminar permanentemente los espacios %1$s con todos sus datos. Esta acción no se puede deshacer. + \ No newline at end of file diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 074c5cd..14d046a 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -537,4 +537,15 @@ Manqué Choisir une tâche + Éléments de la liste + Aucune liste de contrôle pour cette date. + Le suivi commence plus tard ! + Éléments supprimés + Aucune donnée pour le moment + Cohérence des éléments + Série actuelle + Faites glisser horizontalement pour réorganiser + Alerte de suppression ! + Vous êtes sur le point de supprimer définitivement les espaces %1$s avec toutes leurs données. Cette action est irréversible. + \ No newline at end of file diff --git a/app/src/main/res/values-hi-rIN/strings.xml b/app/src/main/res/values-hi-rIN/strings.xml index 8639482..e737195 100644 --- a/app/src/main/res/values-hi-rIN/strings.xml +++ b/app/src/main/res/values-hi-rIN/strings.xml @@ -535,4 +535,16 @@ छूट गया एक टूडू चुनें + + सूची आइटम + इस तिथि के लिए कोई चेकलिस्ट नहीं है। + आपकी ट्रैकिंग बाद में शुरू होगी! + हटाए गए आइटम + अभी तक कोई डेटा नहीं + आइटम निरंतरता + वर्तमान स्ट्रीक + पुनः क्रमित करने के लिए क्षैतिज रूप से खींचें + हटाने की चेतावनी! + %1$s स्पेस और उनका सारा डेटा स्थायी रूप से हटाया जाएगा। इसे वापस नहीं लाया जा सकता। + diff --git a/app/src/main/res/values-nl-rNL/strings.xml b/app/src/main/res/values-nl-rNL/strings.xml index 8d49524..4b87f97 100644 --- a/app/src/main/res/values-nl-rNL/strings.xml +++ b/app/src/main/res/values-nl-rNL/strings.xml @@ -535,4 +535,15 @@ Niet gepland Gemist Kies een taak + + Lijstitems + Geen checklist voor deze datum. + Je tracking begint later! + Verwijderde items + Nog geen gegevens + Itemconsistentie + Huidige reeks + Sleep horizontaal om opnieuw te ordenen + Verwijderingswaarschuwing! + Je staat op het punt %1$s ruimte(n) met alle gegevens permanent te verwijderen. Dit kan niet ongedaan worden gemaakt. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 3bbe8a4..4f38988 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -535,4 +535,15 @@ Não agendado Perdido Escolher uma tarefa + + Itens da lista + Nenhuma lista de verificação para esta data. + Seu acompanhamento começa mais tarde! + Itens removidos + Ainda não há dados + Consistência dos itens + Sequência atual + Arraste horizontalmente para reorganizar + Alerta de exclusão! + Você está prestes a excluir permanentemente o(s) espaço(s) %1$s e todos os seus dados. Esta ação não pode ser desfeita. \ No newline at end of file diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index d22c12a..e5a2791 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -536,4 +536,16 @@ Пропущено Выбрать задачу + + Элементы списка + Для этой даты нет контрольного списка. + Отслеживание начнётся позже! + Удалённые элементы + Пока нет данных + Последовательность выполнения + Текущая серия + Перетащите по горизонтали для изменения порядка + Предупреждение об удалении! + Вы собираетесь навсегда удалить пространство(а) %1$s вместе со всеми данными. Это действие нельзя отменить. + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 68bb2bc..1372cae 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -540,4 +540,15 @@ 未安排 已错过 选择待办事项 + + 列表项 + 此日期没有清单。 + 你的追踪将在稍后开始! + 已移除项目 + 暂无数据 + 项目完成一致性 + 当前连续记录 + 水平拖动以重新排序 + 删除警告! + 你即将永久删除 %1$s 个空间及其所有数据,此操作无法撤销。 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a64834..5bb7545 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -261,7 +261,7 @@ This habit has already ended! You can’t mark future dates! Your habit starts later! - This date isn’t part of your habit schedule. + This date isn’t part of your schedule. Events @@ -534,4 +534,15 @@ Missed Pick a Todo + List Items + No checklist for this date. + Your tracking starts later! + Removed Items + No Data Yet + Item Consistency + Current Streak + Drag horizontally to reorder + Delete Alert! + You are about to delete %1$s space(s) with their data permanently. This can\'t be undone. + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9022142..bbb87f9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,7 @@ adaptive = "1.2.0" foundation = "1.11.4" navigationCommonKtx = "2.9.8" glanceAppwidget = "1.1.1" +uiText = "1.11.4" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } @@ -79,6 +80,7 @@ androidx-compose-foundation = { group = "androidx.compose.foundation", name = "f androidx-navigation-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCommonKtx" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glanceAppwidget" } +androidx-compose-ui-text = { group = "androidx.compose.ui", name = "ui-text", version.ref = "uiText" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }