Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
211 changes: 197 additions & 14 deletions android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -182,21 +196,25 @@ 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,
maxLines = 1,
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),
Expand Down Expand Up @@ -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 {
Expand All @@ -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<LocalDateTime> = 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)
}
Loading