@@ -8,7 +8,9 @@ import androidx.compose.material.icons.Icons
88import androidx.compose.material.icons.filled.Add
99import androidx.compose.material.icons.filled.ExpandLess
1010import androidx.compose.material.icons.filled.ExpandMore
11+ import androidx.compose.material.icons.filled.NotificationsActive
1112import androidx.compose.material.icons.filled.RadioButtonUnchecked
13+ import androidx.compose.material.icons.filled.Repeat
1214import androidx.compose.material.icons.filled.SkipNext
1315import androidx.compose.material.icons.filled.Delete
1416import androidx.compose.material3.*
@@ -20,9 +22,17 @@ import androidx.compose.ui.graphics.Color
2022import androidx.compose.ui.text.style.TextOverflow
2123import androidx.compose.ui.unit.dp
2224import 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
2328import com.dkhalife.tasks.model.Task
29+ import java.time.LocalDate
30+ import java.time.LocalDateTime
31+ import java.time.ZoneId
2432import java.time.ZonedDateTime
2533import 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
234335private fun LabelChip (name : String , color : String ) {
235336 val chipColor = try {
@@ -253,9 +354,95 @@ private fun LabelChip(name: String, color: String) {
253354
254355private 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