diff --git a/android/app/src/main/java/com/dkhalife/tasks/data/TaskGrouper.kt b/android/app/src/main/java/com/dkhalife/tasks/data/TaskGrouper.kt index c619657..1658cce 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/data/TaskGrouper.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/data/TaskGrouper.kt @@ -6,6 +6,7 @@ import com.dkhalife.tasks.model.Task import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalDateTime +import java.time.ZoneId import java.time.ZonedDateTime import java.time.temporal.TemporalAdjusters @@ -57,7 +58,9 @@ object TaskGrouper { } val dueDate = try { - ZonedDateTime.parse(dueDateStr).toLocalDateTime() + ZonedDateTime.parse(dueDateStr) + .withZoneSameInstant(ZoneId.systemDefault()) + .toLocalDateTime() } catch (_: Exception) { anyTime.add(task) continue diff --git a/android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt b/android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt index 7bf7cc2..b3993e1 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt @@ -8,7 +8,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.* @@ -20,9 +22,18 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.dkhalife.tasks.data.TaskGroup +import com.dkhalife.tasks.model.FrequencyType +import com.dkhalife.tasks.model.IntervalUnit +import com.dkhalife.tasks.model.RepeatOn import com.dkhalife.tasks.model.Task +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Locale +import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -160,6 +171,9 @@ private fun TaskItem( onDelete: () -> Unit, onClick: () -> Unit ) { + val ldt = remember(task.nextDueDate) { parseDueDate(task.nextDueDate) } + val now by rememberTickingNow() + Card( modifier = Modifier .fillMaxWidth() @@ -182,6 +196,18 @@ private fun TaskItem( Column( modifier = Modifier.weight(1f) ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 4.dp) + ) { + DueDateChip(ldt, now) + RecurrenceChip(task, ldt) + if (hasActiveNotification(task)) { + NotificationChip() + } + } + Text( text = task.title, style = MaterialTheme.typography.bodyLarge, @@ -189,14 +215,6 @@ private fun TaskItem( overflow = TextOverflow.Ellipsis ) - task.nextDueDate?.let { dueDate -> - Text( - text = formatDueDate(dueDate), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (task.labels.isNotEmpty()) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -230,6 +248,78 @@ private fun TaskItem( } } +@Composable +private fun DueDateChip(ldt: LocalDateTime?, now: LocalDateTime) { + val text = if (ldt == null) "No Due Date" else formatDueDate(ldt, now) + val (bgColor, fgColor) = when { + ldt == null -> Pair(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant) + ldt.isBefore(now) -> Pair(MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer) + ChronoUnit.HOURS.between(now, ldt) < 4 -> Pair(MaterialTheme.colorScheme.tertiaryContainer, MaterialTheme.colorScheme.onTertiaryContainer) + else -> Pair(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant) + } + Surface(shape = MaterialTheme.shapes.extraSmall, color = bgColor) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = fgColor, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } +} + +@Composable +private fun RecurrenceChip(task: Task, nextDueLdt: LocalDateTime?) { + val text = getRecurrenceText(task, nextDueLdt) + val isOnce = task.frequency.type == FrequencyType.ONCE + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (isOnce) { + Text( + text = "1\u00D7", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Icon( + Icons.Default.Repeat, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(12.dp) + ) + } + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun NotificationChip() { + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Icon( + Icons.Default.NotificationsActive, + contentDescription = "Notifications active", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 6.dp, vertical = 3.dp) + .size(12.dp) + ) + } +} + @Composable private fun LabelChip(name: String, color: String) { val chipColor = try { @@ -251,11 +341,104 @@ private fun LabelChip(name: String, color: String) { } } -private fun formatDueDate(dateStr: String): String { - return try { - val zdt = ZonedDateTime.parse(dateStr) - zdt.format(DateTimeFormatter.ofPattern("MMM d, yyyy")) - } catch (_: Exception) { - dateStr +private fun parseDueDate(dateStr: String?): LocalDateTime? = + dateStr?.let { + try { + ZonedDateTime.parse(it).withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime() + } catch (_: Exception) { null } + } + +@Composable +private fun rememberTickingNow(): State = produceState(LocalDateTime.now()) { + while (true) { + delay(60_000L) + value = LocalDateTime.now() } } + +private fun formatDueDate(ldt: LocalDateTime, now: LocalDateTime): String { + val today = now.toLocalDate() + val timeStr = ldt.format(DateTimeFormatter.ofPattern("hh:mm a", Locale.ENGLISH)) + return when { + ldt.isBefore(now) -> "${formatDistance(ldt, now)} ago" + ldt.toLocalDate() == today -> "Today at $timeStr" + ldt.toLocalDate() == today.plusDays(1) -> "Tomorrow at $timeStr" + else -> "in ${formatDistance(now, ldt)}" + } +} + +private fun formatDistance(from: LocalDateTime, to: LocalDateTime): String { + val seconds = ChronoUnit.SECONDS.between(from, to) + val minutes = seconds / 60 + return when { + seconds < 45 -> "less than a minute" + seconds < 90 -> "1 minute" + minutes < 45 -> "$minutes minutes" + minutes < 90 -> "about 1 hour" + minutes < 1440 -> "about ${minutes / 60} hours" + minutes < 2520 -> "1 day" + minutes < 43200 -> "${minutes / 1440} days" + minutes < 64800 -> "about 1 month" + minutes < 86400 -> "about 2 months" + minutes < 525600 -> "${minutes / 43200} months" + else -> "about ${minutes / 525600} years" + } +} + +private fun getRecurrenceText(task: Task, nextDueLdt: LocalDateTime?): String { + val frequency = task.frequency + return when (frequency.type) { + FrequencyType.ONCE -> "Once" + FrequencyType.DAILY -> "Daily" + FrequencyType.WEEKLY -> "Weekly" + FrequencyType.MONTHLY -> "Monthly" + FrequencyType.YEARLY -> "Yearly" + FrequencyType.CUSTOM -> when (frequency.on) { + RepeatOn.INTERVAL -> { + val every = frequency.every ?: 1 + if (every == 1) { + when (frequency.unit) { + IntervalUnit.HOURS -> "Hourly" + IntervalUnit.DAYS -> "Daily" + IntervalUnit.WEEKS -> "Weekly" + IntervalUnit.MONTHS -> "Monthly" + IntervalUnit.YEARS -> "Yearly" + else -> "Custom" + } + } else { + frequency.unit?.let { "Every $every $it" } ?: "Custom" + } + } + RepeatOn.DAYS_OF_THE_WEEK -> { + val dayNames = arrayOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat") + frequency.days?.joinToString(", ") { dayNames.getOrElse(it) { "$it" } } ?: "Weekly" + } + RepeatOn.DAY_OF_THE_MONTHS -> { + val day = nextDueLdt?.dayOfMonth ?: 0 + val suffix = getDayOfMonthSuffix(day) + val monthNames = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + val months = frequency.months?.joinToString(", ") { + monthNames.getOrElse(it) { "$it" } + } ?: "" + "${day}${suffix} of $months" + } + else -> "" + } + else -> "" + } +} + +private fun getDayOfMonthSuffix(day: Int): String { + return if (day in 11..13) "th" + else when (day % 10) { + 1 -> "st" + 2 -> "nd" + 3 -> "rd" + else -> "th" + } +} + +private fun hasActiveNotification(task: Task): Boolean { + return task.notification.enabled && + (task.notification.dueDate || task.notification.preDue || task.notification.overdue) +}