package org.terst.doot.widget.ui import android.content.Context import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.datastore.preferences.core.Preferences import androidx.glance.* import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.provideContent import androidx.glance.layout.* import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextStyle import androidx.glance.unit.ColorProvider import kotlinx.coroutines.flow.first import kotlinx.serialization.json.Json import org.terst.doot.widget.data.* import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime import java.time.temporal.ChronoUnit fun sourceColor(source: String): Color = when (source) { "doot" -> Color(0xFF14B8A6) "todoist" -> Color(0xFFE44332) "trello" -> Color(0xFF0079BF) "calendar" -> Color(0xFF9B59B6) "plantoeat" -> Color(0xFF5CB85C) "gtasks" -> Color(0xFFF39C12) else -> Color(0xFF888888) } private val json = Json { ignoreUnknownKeys = true } class DootWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val prefs = context.dataStore.data.first() val items = parseItems(prefs) val now = prefs[Keys.NOW]?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: Instant.now() val isRefreshing = prefs[Keys.IS_REFRESHING] ?: false provideContent { WidgetRoot(items, now, isRefreshing) } } private fun parseItems(prefs: Preferences): List { val raw = prefs[Keys.ITEMS_JSON] ?: return emptyList() return runCatching { json.decodeFromString>(raw) }.getOrDefault(emptyList()) } } @Composable fun WidgetRoot(items: List, now: Instant, isRefreshing: Boolean) { val zone = ZoneId.systemDefault() val nowZoned: ZonedDateTime = now.atZone(zone) val todayStart = nowZoned.toLocalDate().atStartOfDay(zone).toInstant() val tomorrowStart = nowZoned.toLocalDate().plusDays(1).atStartOfDay(zone).toInstant() val tomorrowEnd = tomorrowStart.plus(1, ChronoUnit.DAYS) // All-day CALENDAR EVENTS (isAllDay && type == "event" -- see // TimelineItemToWidgetItem's doc comment for why undated doot/gtask // tasks, which are also flagged isAllDay, are deliberately excluded // here) are pinned to the top of their day's section and never compete // for hourly grid slots or floating-task packing. Previously they had // no Start at all and fell into the same floating-task queue as // ordinary untimed tasks, where enough tasks ahead of them in the queue // could push their assigned slot past the visible grid range entirely // -- not merely unpinned, actually invisible. val allDayEvents = items.filter { it.isAllDay && it.type == "event" } val rest = items.filter { !(it.isAllDay && it.type == "event") } val todayAllDay = allDayEvents.filter { item -> val s = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } s == null || (s >= todayStart && s < tomorrowStart) } val tomorrowAllDay = allDayEvents.filter { item -> val s = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } s != null && s >= tomorrowStart && s < tomorrowEnd } val allScheduled = rest.filter { it.start != null }.sortedBy { Instant.parse(it.start!!) } // Past tasks float at now (before untimed tasks); past events stay in the grid at 50% alpha val pastTasks = allScheduled.filter { it.type == "task" && Instant.parse(it.start!!) < now } val scheduledEvents = allScheduled.filter { it.type != "task" || Instant.parse(it.start!!) >= now } val floating = rest.filter { it.start == null } val fragments = SlotPacker.pack(pastTasks + floating, scheduledEvents, now) val gridStart = calcGridStart(scheduledEvents, nowZoned.hour) val gridEnd = calcGridEnd(scheduledEvents, nowZoned.hour) val tomorrowItems = scheduledEvents .filter { Instant.parse(it.start!!) >= tomorrowStart && Instant.parse(it.start!!) < tomorrowEnd } val tomorrowFrags = fragments .filter { it.startTime >= tomorrowStart && it.startTime < tomorrowEnd } Column( modifier = GlanceModifier .fillMaxSize() .background(Color.Transparent) .padding(horizontal = 8.dp, vertical = 4.dp) ) { Row( modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp), verticalAlignment = Alignment.CenterVertically ) { Text( "TODAY", style = TextStyle( color = ColorProvider(Color(0x66FFFFFF)), fontSize = 11.sp, fontWeight = FontWeight.Bold ), modifier = GlanceModifier.defaultWeight() ) QuickAddButton() Spacer(modifier = GlanceModifier.width(4.dp)) RefreshButton(isRefreshing) } todayAllDay.forEach { AllDayRow(it) } for (hour in gridStart..gridEnd) { HourRow(hour, nowZoned, scheduledEvents, fragments, zone) } if (tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() } || tomorrowAllDay.isNotEmpty()) { TomorrowSection(tomorrowItems, tomorrowFrags, tomorrowAllDay, zone) } } } @Composable fun AllDayRow(event: WidgetItem) { val context = LocalContext.current val detailIntent = Intent(context, EventDetailActivity::class.java).apply { putExtra(EventDetailActivity.EXTRA_TITLE, event.title) putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) putExtra(EventDetailActivity.EXTRA_URL, event.url) putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } val color = sourceColor(event.source) Row( modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 3.dp) .clickable(actionStartActivity(detailIntent)), verticalAlignment = Alignment.CenterVertically ) { Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {} Text( text = event.title, style = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.9f)), fontSize = 13.sp, fontWeight = FontWeight.Medium), modifier = GlanceModifier.padding(start = 8.dp), maxLines = 1 ) } } @Composable fun RefreshButton(isRefreshing: Boolean) { Box( modifier = GlanceModifier .size(24.dp) .clickable(actionRunCallback()), contentAlignment = Alignment.Center ) { Image( provider = ImageProvider( if (isRefreshing) org.terst.doot.widget.R.drawable.ic_refresh_loading else org.terst.doot.widget.R.drawable.ic_refresh ), contentDescription = if (isRefreshing) "Refreshing" else "Refresh", colorFilter = ColorFilter.tint(ColorProvider(Color(0x99FFFFFF))), modifier = GlanceModifier.size(14.dp) ) } } @Composable fun QuickAddButton() { val context = LocalContext.current val intent = Intent(context, QuickAddActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } Box( modifier = GlanceModifier .size(24.dp) .clickable(actionStartActivity(intent)), contentAlignment = Alignment.Center ) { Image( provider = ImageProvider(org.terst.doot.widget.R.drawable.ic_add), contentDescription = "Add task", colorFilter = ColorFilter.tint(ColorProvider(Color(0x99FFFFFF))), modifier = GlanceModifier.size(14.dp) ) } } @Composable fun HourRow( hour: Int, nowZoned: ZonedDateTime, scheduled: List, fragments: List, @Suppress("UNUSED_PARAMETER") zone: ZoneId ) { val hourStart = nowZoned.withHour(hour).withMinute(0).withSecond(0).withNano(0).toInstant() val hourEnd = hourStart.plus(1, ChronoUnit.HOURS) val isNowHour = nowZoned.hour == hour val eventsHere = scheduled.filter { event -> event.start?.let { s -> val start = Instant.parse(s) start >= hourStart && start < hourEnd } ?: false } val fragsHere = fragments.filter { frag -> frag.startTime >= hourStart && frag.startTime < hourEnd } Row( modifier = GlanceModifier.fillMaxWidth().wrapContentHeight(), verticalAlignment = Alignment.Top ) { Text( text = hourLabel(hour), style = TextStyle(color = ColorProvider(Color(0x4DFFFFFF)), fontSize = 10.sp), modifier = GlanceModifier.width(32.dp).padding(top = 2.dp) ) Column(modifier = GlanceModifier.defaultWeight()) { // Hour divider line Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).background(Color(0x1AFFFFFF))) {} // NOW indicator if (isNowHour) { Box( modifier = GlanceModifier .fillMaxWidth() .height(1.dp) .background(Color(0xBFFFFFFF.toInt())) .padding(vertical = 2.dp) ) {} } eventsHere.forEach { event -> val isPast = event.end?.let { Instant.parse(it) < nowZoned.toInstant() } ?: false EventBlock(event, isPast) } fragsHere.forEach { frag -> TaskFragmentBlock(frag) } } } } @Composable fun EventBlock(event: WidgetItem, isPast: Boolean) { val context = LocalContext.current val detailIntent = Intent(context, EventDetailActivity::class.java).apply { putExtra(EventDetailActivity.EXTRA_TITLE, event.title) putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) putExtra(EventDetailActivity.EXTRA_URL, event.url) putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } val color = sourceColor(event.source) val alpha = if (isPast) 0.5f else 1f Row( modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 4.dp) .clickable(actionStartActivity(detailIntent)), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = GlanceModifier .width(3.dp) .height(20.dp) .background(color.copy(alpha = alpha)) ) {} Text( text = event.title, style = TextStyle( color = ColorProvider(Color.White.copy(alpha = alpha)), fontSize = 14.sp ), modifier = GlanceModifier.padding(start = 8.dp), maxLines = 1 ) } } @Composable fun TaskFragmentBlock(fragment: TaskFragment) { Column( modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 2.dp) ) { Text( "TODAY", style = TextStyle( color = ColorProvider(Color(0x73FFFFFF)), fontSize = 9.sp, fontWeight = FontWeight.Bold ), modifier = GlanceModifier.padding(start = 8.dp, top = 3.dp, bottom = 1.dp) ) fragment.slots.forEach { slot -> TaskRow(slot.task) } } } @Composable fun TaskRow(task: WidgetItem) { val context = LocalContext.current val detailIntent = Intent(context, TaskDetailActivity::class.java).apply { putExtra(TaskDetailActivity.EXTRA_ID, task.id) putExtra(TaskDetailActivity.EXTRA_SOURCE, task.source) putExtra(TaskDetailActivity.EXTRA_TITLE, task.title) putExtra(TaskDetailActivity.EXTRA_COMPLETABLE, task.completable) putExtra(TaskDetailActivity.EXTRA_DUE_DATE, task.dueDate) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } // Split into two sibling clickable regions to avoid the Glance/RemoteViews limitation // where a parent clickable silently overrides a nested child clickable. The checkbox // gets its own non-overlapping Box; the title region handles the detail-open action. Row( modifier = GlanceModifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically ) { if (task.completable) { Box( modifier = GlanceModifier .size(24.dp) .clickable( actionRunCallback( actionParametersOf( CompleteTaskAction.idKey to task.id, CompleteTaskAction.sourceKey to task.source ) ) ), contentAlignment = Alignment.Center ) { Image( provider = ImageProvider(org.terst.doot.widget.R.drawable.ic_checkbox_empty), contentDescription = "Complete ${task.title}", colorFilter = ColorFilter.tint(ColorProvider(sourceColor(task.source))), modifier = GlanceModifier.size(14.dp) ) } } else { Box( modifier = GlanceModifier .size(8.dp) .background(sourceColor(task.source).copy(alpha = 0.6f)) ) {} } Box( modifier = GlanceModifier .defaultWeight() .clickable(actionStartActivity(detailIntent)) .padding(start = 8.dp), contentAlignment = Alignment.CenterStart ) { Text( text = task.title, style = TextStyle( color = ColorProvider( if (task.isOverdue) Color(0xFFF87171) else Color(0xFFDDDDDD.toInt()) ), fontSize = 14.sp ), maxLines = 1 ) } } } @Composable fun TomorrowSection(items: List, fragments: List, allDayEvents: List, zone: ZoneId) { Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).padding(vertical = 4.dp).background(Color(0x1AFFFFFF))) {} Row(modifier = GlanceModifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp)) { Text( "TOMORROW", style = TextStyle( color = ColorProvider(Color(0x66FFFFFF)), fontSize = 11.sp, fontWeight = FontWeight.Bold ) ) } allDayEvents.forEach { AllDayRow(it) } items.forEach { item -> val isPast = false if (item.type == "event") { TomorrowEventRow(item, zone) } else { TaskRow(item) } } fragments.forEach { frag -> frag.slots.forEach { slot -> TaskRow(slot.task) } } } @Composable fun TomorrowEventRow(event: WidgetItem, zone: ZoneId) { val context = LocalContext.current val detailIntent = Intent(context, EventDetailActivity::class.java).apply { putExtra(EventDetailActivity.EXTRA_TITLE, event.title) putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) putExtra(EventDetailActivity.EXTRA_URL, event.url) putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } val color = sourceColor(event.source) val timeLabel = event.start?.let { val t = Instant.parse(it).atZone(zone) hourLabel(t.hour) + if (t.minute > 0) t.minute.toString().padStart(2, '0') else "" } ?: "" Row( modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 3.dp) .clickable(actionStartActivity(detailIntent)), verticalAlignment = Alignment.CenterVertically ) { Text( text = timeLabel, style = TextStyle(color = ColorProvider(Color(0x4DFFFFFF)), fontSize = 10.sp), modifier = GlanceModifier.width(32.dp) ) Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {} Text( text = event.title, style = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.75f)), fontSize = 13.sp), modifier = GlanceModifier.padding(start = 8.dp), maxLines = 1 ) } } private fun hourLabel(hour: Int): String = when { hour == 0 -> "12a" hour < 12 -> "${hour}a" hour == 12 -> "12p" else -> "${hour - 12}p" } private fun calcGridStart(events: List, nowHour: Int): Int { val earliest = events.mapNotNull { it.start } .mapNotNull { runCatching { Instant.parse(it) }.getOrNull() } .minOrNull() ?.atZone(ZoneId.systemDefault())?.hour ?: nowHour return maxOf(0, minOf(earliest, nowHour) - 1) } private fun calcGridEnd(events: List, nowHour: Int): Int { val latest = events.mapNotNull { it.end } .mapNotNull { runCatching { Instant.parse(it) }.getOrNull() } .maxOrNull() ?.atZone(ZoneId.systemDefault())?.hour ?: (nowHour + 8) return minOf(23, maxOf(latest, nowHour + 1) + 1) }