Skip to content

Commit 31122d8

Browse files
committed
task list display
1 parent e45ec82 commit 31122d8

File tree

2 files changed

+201
-11
lines changed

2 files changed

+201
-11
lines changed

android/app/src/main/java/com/dkhalife/tasks/data/TaskGrouper.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.dkhalife.tasks.model.Task
66
import java.time.DayOfWeek
77
import java.time.LocalDate
88
import java.time.LocalDateTime
9+
import java.time.ZoneId
910
import java.time.ZonedDateTime
1011
import java.time.temporal.TemporalAdjusters
1112

@@ -57,7 +58,9 @@ object TaskGrouper {
5758
}
5859

5960
val dueDate = try {
60-
ZonedDateTime.parse(dueDateStr).toLocalDateTime()
61+
ZonedDateTime.parse(dueDateStr)
62+
.withZoneSameInstant(ZoneId.systemDefault())
63+
.toLocalDateTime()
6164
} catch (_: Exception) {
6265
anyTime.add(task)
6366
continue

android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt

Lines changed: 197 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import androidx.compose.material.icons.Icons
88
import androidx.compose.material.icons.filled.Add
99
import androidx.compose.material.icons.filled.ExpandLess
1010
import androidx.compose.material.icons.filled.ExpandMore
11+
import androidx.compose.material.icons.filled.NotificationsActive
1112
import androidx.compose.material.icons.filled.RadioButtonUnchecked
13+
import androidx.compose.material.icons.filled.Repeat
1214
import androidx.compose.material.icons.filled.SkipNext
1315
import androidx.compose.material.icons.filled.Delete
1416
import androidx.compose.material3.*
@@ -20,9 +22,17 @@ import androidx.compose.ui.graphics.Color
2022
import androidx.compose.ui.text.style.TextOverflow
2123
import androidx.compose.ui.unit.dp
2224
import com.dkhalife.tasks.data.TaskGroup
25+
import com.dkhalife.tasks.model.FrequencyType
26+
import com.dkhalife.tasks.model.IntervalUnit
27+
import com.dkhalife.tasks.model.RepeatOn
2328
import com.dkhalife.tasks.model.Task
29+
import java.time.LocalDate
30+
import java.time.LocalDateTime
31+
import java.time.ZoneId
2432
import java.time.ZonedDateTime
2533
import java.time.format.DateTimeFormatter
34+
import java.time.temporal.ChronoUnit
35+
import java.util.Locale
2636

2737
@OptIn(ExperimentalMaterial3Api::class)
2838
@Composable
@@ -182,21 +192,25 @@ private fun TaskItem(
182192
Column(
183193
modifier = Modifier.weight(1f)
184194
) {
195+
Row(
196+
horizontalArrangement = Arrangement.spacedBy(4.dp),
197+
verticalAlignment = Alignment.CenterVertically,
198+
modifier = Modifier.padding(bottom = 4.dp)
199+
) {
200+
DueDateChip(task.nextDueDate)
201+
RecurrenceChip(task)
202+
if (hasActiveNotification(task)) {
203+
NotificationChip()
204+
}
205+
}
206+
185207
Text(
186208
text = task.title,
187209
style = MaterialTheme.typography.bodyLarge,
188210
maxLines = 1,
189211
overflow = TextOverflow.Ellipsis
190212
)
191213

192-
task.nextDueDate?.let { dueDate ->
193-
Text(
194-
text = formatDueDate(dueDate),
195-
style = MaterialTheme.typography.bodySmall,
196-
color = MaterialTheme.colorScheme.onSurfaceVariant
197-
)
198-
}
199-
200214
if (task.labels.isNotEmpty()) {
201215
Row(
202216
horizontalArrangement = Arrangement.spacedBy(4.dp),
@@ -230,6 +244,93 @@ private fun TaskItem(
230244
}
231245
}
232246

247+
@Composable
248+
private fun DueDateChip(dateStr: String?) {
249+
val ldt = remember(dateStr) {
250+
dateStr?.let {
251+
try {
252+
ZonedDateTime.parse(it).withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime()
253+
} catch (_: Exception) { null }
254+
}
255+
}
256+
val now = LocalDateTime.now()
257+
val text = if (dateStr == null) "No Due Date" else formatDueDate(dateStr)
258+
val (bgColor, fgColor) = when {
259+
ldt == null -> Pair(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)
260+
ldt.isBefore(now) -> Pair(MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer)
261+
ChronoUnit.HOURS.between(now, ldt) < 4 -> Pair(MaterialTheme.colorScheme.tertiaryContainer, MaterialTheme.colorScheme.onTertiaryContainer)
262+
else -> Pair(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)
263+
}
264+
Surface(shape = MaterialTheme.shapes.extraSmall, color = bgColor) {
265+
Text(
266+
text = text,
267+
style = MaterialTheme.typography.labelSmall,
268+
color = fgColor,
269+
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp)
270+
)
271+
}
272+
}
273+
274+
@Composable
275+
private fun RecurrenceChip(task: Task) {
276+
val nextDueLdt = remember(task.nextDueDate) {
277+
task.nextDueDate?.let {
278+
try {
279+
ZonedDateTime.parse(it).withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime()
280+
} catch (_: Exception) { null }
281+
}
282+
}
283+
val text = getRecurrenceText(task, nextDueLdt)
284+
val isOnce = task.frequency.type == FrequencyType.ONCE
285+
Surface(
286+
shape = MaterialTheme.shapes.extraSmall,
287+
color = MaterialTheme.colorScheme.surfaceVariant
288+
) {
289+
Row(
290+
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
291+
verticalAlignment = Alignment.CenterVertically,
292+
horizontalArrangement = Arrangement.spacedBy(2.dp)
293+
) {
294+
if (isOnce) {
295+
Text(
296+
text = "1\u00D7",
297+
style = MaterialTheme.typography.labelSmall,
298+
color = MaterialTheme.colorScheme.onSurfaceVariant
299+
)
300+
} else {
301+
Icon(
302+
Icons.Default.Repeat,
303+
contentDescription = null,
304+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
305+
modifier = Modifier.size(12.dp)
306+
)
307+
}
308+
Text(
309+
text = text,
310+
style = MaterialTheme.typography.labelSmall,
311+
color = MaterialTheme.colorScheme.onSurfaceVariant
312+
)
313+
}
314+
}
315+
}
316+
317+
@Composable
318+
private fun NotificationChip() {
319+
Surface(
320+
shape = MaterialTheme.shapes.extraSmall,
321+
color = MaterialTheme.colorScheme.surfaceVariant
322+
) {
323+
Icon(
324+
Icons.Default.NotificationsActive,
325+
contentDescription = "Notifications active",
326+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
327+
modifier = Modifier
328+
.padding(horizontal = 6.dp, vertical = 3.dp)
329+
.size(12.dp)
330+
)
331+
}
332+
}
333+
233334
@Composable
234335
private fun LabelChip(name: String, color: String) {
235336
val chipColor = try {
@@ -253,9 +354,95 @@ private fun LabelChip(name: String, color: String) {
253354

254355
private fun formatDueDate(dateStr: String): String {
255356
return try {
256-
val zdt = ZonedDateTime.parse(dateStr)
257-
zdt.format(DateTimeFormatter.ofPattern("MMM d, yyyy"))
357+
val ldt = ZonedDateTime.parse(dateStr)
358+
.withZoneSameInstant(ZoneId.systemDefault())
359+
.toLocalDateTime()
360+
val now = LocalDateTime.now()
361+
val today = LocalDate.now()
362+
val timeStr = ldt.format(DateTimeFormatter.ofPattern("hh:mm a", Locale.ENGLISH))
363+
when {
364+
ldt.isBefore(now) -> "${formatDistance(ldt, now)} ago"
365+
ldt.toLocalDate() == today -> "Today at $timeStr"
366+
ldt.toLocalDate() == today.plusDays(1) -> "Tomorrow at $timeStr"
367+
else -> "in ${formatDistance(now, ldt)}"
368+
}
258369
} catch (_: Exception) {
259370
dateStr
260371
}
261372
}
373+
374+
private fun formatDistance(from: LocalDateTime, to: LocalDateTime): String {
375+
val seconds = ChronoUnit.SECONDS.between(from, to)
376+
val minutes = seconds / 60
377+
return when {
378+
seconds < 45 -> "less than a minute"
379+
seconds < 90 -> "1 minute"
380+
minutes < 45 -> "$minutes minutes"
381+
minutes < 90 -> "about 1 hour"
382+
minutes < 1440 -> "about ${minutes / 60} hours"
383+
minutes < 2520 -> "1 day"
384+
minutes < 43200 -> "${minutes / 1440} days"
385+
minutes < 64800 -> "about 1 month"
386+
minutes < 86400 -> "about 2 months"
387+
minutes < 525600 -> "${minutes / 43200} months"
388+
else -> "about ${minutes / 525600} years"
389+
}
390+
}
391+
392+
private fun getRecurrenceText(task: Task, nextDueLdt: LocalDateTime?): String {
393+
val frequency = task.frequency
394+
return when (frequency.type) {
395+
FrequencyType.ONCE -> "Once"
396+
FrequencyType.DAILY -> "Daily"
397+
FrequencyType.WEEKLY -> "Weekly"
398+
FrequencyType.MONTHLY -> "Monthly"
399+
FrequencyType.YEARLY -> "Yearly"
400+
FrequencyType.CUSTOM -> when (frequency.on) {
401+
RepeatOn.INTERVAL -> {
402+
val every = frequency.every ?: 1
403+
if (every == 1) {
404+
when (frequency.unit) {
405+
IntervalUnit.HOURS -> "Hourly"
406+
IntervalUnit.DAYS -> "Daily"
407+
IntervalUnit.WEEKS -> "Weekly"
408+
IntervalUnit.MONTHS -> "Monthly"
409+
IntervalUnit.YEARS -> "Yearly"
410+
else -> "Every $every ${frequency.unit}"
411+
}
412+
} else {
413+
"Every $every ${frequency.unit}"
414+
}
415+
}
416+
RepeatOn.DAYS_OF_THE_WEEK -> {
417+
val dayNames = arrayOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat")
418+
frequency.days?.joinToString(", ") { dayNames.getOrElse(it) { "$it" } } ?: "Weekly"
419+
}
420+
RepeatOn.DAY_OF_THE_MONTHS -> {
421+
val day = nextDueLdt?.dayOfMonth ?: 0
422+
val suffix = getDayOfMonthSuffix(day)
423+
val monthNames = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
424+
val months = frequency.months?.joinToString(", ") {
425+
monthNames.getOrElse(it) { "$it" }
426+
} ?: ""
427+
"${day}${suffix} of $months"
428+
}
429+
else -> ""
430+
}
431+
else -> ""
432+
}
433+
}
434+
435+
private fun getDayOfMonthSuffix(day: Int): String {
436+
return if (day in 11..13) "th"
437+
else when (day % 10) {
438+
1 -> "st"
439+
2 -> "nd"
440+
3 -> "rd"
441+
else -> "th"
442+
}
443+
}
444+
445+
private fun hasActiveNotification(task: Task): Boolean {
446+
return task.notification.enabled &&
447+
(task.notification.dueDate || task.notification.preDue || task.notification.overdue)
448+
}

0 commit comments

Comments
 (0)