package org.terst.doot.widget.ui import android.content.Intent import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.glance.* import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity 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 org.terst.doot.widget.data.* import java.time.Instant import java.time.LocalDate 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) } /** Parses a "#RRGGBB" string into a Glance-compatible Color, defaulting to gray on failure. */ internal fun parseGlanceHexColor(hex: String): Color = runCatching { Color(android.graphics.Color.parseColor(hex)) }.getOrDefault(Color.Gray) private val HALO_INSET = 1.dp private val HALO_SPAN = 2.dp /** * Glance's TextStyle has no shadow parameter (checked through 1.3.0-alpha02, * the latest available build) and RemoteViews doesn't expose * TextView.setShadowLayer as a remotable action, so there's no direct API * for a text drop shadow in an app widget. Faked instead by stacking four * copies of the text at the four diagonal corners around the real text, * forming a halo/outline -- not a single offset drop shadow. * * A single-corner shadow (the original version of this, 2026-07-28) only * protects legibility on the one side it's offset toward; the other three * sides of every glyph get no help, so text over a busy/light region of the * wallpaper on those sides still washes out (reported 2026-08-06). Four * corners is the "poor man's text outline" technique long used for exactly * this class of problem (subtitles, map labels, game HUD text) wherever the * background can't be controlled or blurred. True N/S/E/W-neighbor outline * would be marginally more even, but Glance's Box has no negative-padding * or per-child-alignment primitive to express that -- only "push further * from the shared top-start anchor" -- so diagonal corners is what's * actually achievable with the stacking trick already in use here, and it * reads the same as true 4-neighbor at this offset distance. * * [haloColor] must be passed by the caller (usually palette.haloColor), not * hardcoded here: a dark halo behind already-dark text (every light-mode * palette variant, which uses system_*_900/_700 shades) does essentially * nothing, since dark-on-dark blends into itself regardless of the * wallpaper behind it. Bug found 2026-08-06 -- this used to be a module-level * constant, always black, silently ineffective for half the themes. * * Cost: 5 Text nodes per call instead of 2, i.e. every call site's share of * the widget's RemoteViews node count goes up 2.5x. Verified this doesn't * cause rendering problems by building and installing on a real device * emulator rather than assuming -- if a future change adds enough more text * rows that this becomes a real concern, drop to 2 opposite corners before * reverting to a single-side shadow. */ @Composable fun ShadowedText( text: String, style: TextStyle, haloColor: Color, modifier: GlanceModifier = GlanceModifier, maxLines: Int = Int.MAX_VALUE ) { val shadowStyle = style.copy(color = ColorProvider(haloColor)) Box(modifier = modifier) { Text(text = text, style = shadowStyle, maxLines = maxLines) // top-left corner Text( text = text, style = shadowStyle, maxLines = maxLines, modifier = GlanceModifier.padding(start = HALO_SPAN) // top-right corner ) Text( text = text, style = shadowStyle, maxLines = maxLines, modifier = GlanceModifier.padding(top = HALO_SPAN) // bottom-left corner ) Text( text = text, style = shadowStyle, maxLines = maxLines, modifier = GlanceModifier.padding(start = HALO_SPAN, top = HALO_SPAN) // bottom-right corner ) Text( text = text, style = style, maxLines = maxLines, modifier = GlanceModifier.padding(start = HALO_INSET, top = HALO_INSET) // real text, centered ) } } /** Opens the event's source URL (Google Calendar, Plan to Eat, etc.) directly. */ fun calendarViewIntent(url: String): Intent = Intent(Intent.ACTION_VIEW, Uri.parse(url.ifEmpty { "https://calendar.google.com" })).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } @Composable fun AllDayRow( event: WidgetItem, textSize: WidgetTextSize, variant: MultiDayVariant = MultiDayVariant.NONE, palette: WidgetPalette, titleColor: Color = palette.textPrimary ) { val label = multiDayLabel(event, variant) Row( modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 3.dp) .clickable(actionStartActivity(calendarViewIntent(event.url))), verticalAlignment = Alignment.CenterVertically ) { // Matches HourRow's 32dp hour-label gutter so this row's text lines up // with EventBlock's text in the grid below it, instead of sitting flush left. Spacer(modifier = GlanceModifier.width(32.dp)) ShadowedText( text = event.title, style = TextStyle( color = ColorProvider(titleColor), fontSize = textSize.scaledContentSize(13), fontWeight = textSize.scaledContentWeight(FontWeight.Medium) ), haloColor = palette.haloColor, modifier = GlanceModifier.padding(start = 8.dp), maxLines = 1 ) if (label.isNotEmpty()) { ShadowedText( // Full opacity, matching EventBlock's upcoming (non-past) events -- // the label is time-relevant info, not secondary chrome. text = label, style = TextStyle( color = ColorProvider(titleColor), fontSize = textSize.scaledContentSize(13), fontWeight = textSize.scaledContentWeight(FontWeight.Medium) ), haloColor = palette.haloColor, modifier = GlanceModifier.padding(start = 4.dp), maxLines = 1 ) } } } @Composable fun RefreshButton(isRefreshing: Boolean, palette: WidgetPalette) { 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(palette.textSecondary)), modifier = GlanceModifier.size(14.dp) ) } } @Composable fun QuickAddButton(palette: WidgetPalette) { 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(palette.textSecondary)), modifier = GlanceModifier.size(14.dp) ) } } @Composable fun HourRow( hour: Int, nowZoned: ZonedDateTime, scheduled: List, fragments: List, @Suppress("UNUSED_PARAMETER") zone: ZoneId, textSize: WidgetTextSize, palette: WidgetPalette, hideCheckboxes: Boolean = false ) { 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 ) { ShadowedText( text = hourLabel(hour), style = TextStyle( color = ColorProvider(palette.textMuted), fontSize = textSize.scaledHeaderSize(10), fontWeight = textSize.scaledHeaderWeight(FontWeight.Normal) ), haloColor = palette.haloColor, modifier = GlanceModifier.width(32.dp).padding(top = 2.dp) ) Column(modifier = GlanceModifier.defaultWeight()) { // Hour divider line Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).background(palette.divider)) {} // NOW indicator if (isNowHour) { Box( modifier = GlanceModifier .fillMaxWidth() .height(1.dp) .background(palette.nowLine) .padding(vertical = 2.dp) ) {} } // Past events never reach here -- WidgetRoot pulls them out of `scheduled` // before this point (see isPastEvent) and renders them as list rows above // the grid instead, the same way past tasks already float above it. eventsHere.forEach { event -> EventBlock(event, textSize, palette) } fragsHere.forEach { frag -> TaskFragmentBlock(frag, textSize, palette, hideCheckboxes) } } } } @Composable fun EventBlock(event: WidgetItem, textSize: WidgetTextSize, palette: WidgetPalette) { Row( modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 4.dp) .clickable(actionStartActivity(calendarViewIntent(event.url))), verticalAlignment = Alignment.CenterVertically ) { ShadowedText( text = event.title, style = TextStyle( color = ColorProvider(palette.textPrimary), fontSize = textSize.scaledContentSize(14), fontWeight = textSize.scaledContentWeight(FontWeight.Normal) ), haloColor = palette.haloColor, modifier = GlanceModifier.padding(start = 8.dp), maxLines = 1 ) } } @Composable fun TaskFragmentBlock(fragment: TaskFragment, textSize: WidgetTextSize, palette: WidgetPalette, hideCheckboxes: Boolean = false) { Column( modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 2.dp) ) { fragment.slots.forEach { slot -> TaskRow(slot.task, textSize, palette, hideCheckboxes) } } } @Composable fun TaskRow( task: WidgetItem, textSize: WidgetTextSize, palette: WidgetPalette, hideCheckboxes: Boolean = false, titleColor: Color = palette.textPrimary, // Null (default) preserves the grid's existing behavior, where HourRow's own // hour-label column already supplies the 32dp gutter externally and this row // starts flush at the Column edge. TomorrowSection passes 32.dp so its task // rows reserve the same leading width as AllDayRow's Spacer(32.dp) and // TimeLabeledEventRow's width(32.dp) time label, keeping titles aligned with // tomorrow's events regardless of hideCheckboxes/completable/dot sizing. gutterWidth: Dp? = null ) { 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( start = if (gutterWidth != null) 0.dp else 8.dp, end = 8.dp, top = 5.dp, bottom = 5.dp ), verticalAlignment = Alignment.CenterVertically ) { // Suppressing checkboxes drops this leading element entirely rather than just // hiding its icon -- the title Box below already carries its own 8dp start // padding (matching EventBlock's), so with nothing ahead of it in the Row, // task titles land flush with event titles instead of offset by unused space. // Tradeoff: tap-to-complete-from-the-widget goes with it (TaskDetailActivity's // Complete button still works), which is what "so everything lines up" implies. // // When gutterWidth is set (TomorrowSection), that reasoning doesn't apply -- // the row it's aligning against (TimeLabeledEventRow) always reserves a fixed // 32dp leading width whether or not there's a visible label there, so this // leading slot is reserved too, checkboxes shown or not. val leadingContent: @Composable () -> Unit = { if (!hideCheckboxes) { 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(palette.textSecondary)), modifier = GlanceModifier.size(14.dp) ) } } else { Box( modifier = GlanceModifier .size(8.dp) .background(palette.textMuted) ) {} } } } if (gutterWidth != null) { Box(modifier = GlanceModifier.width(gutterWidth), contentAlignment = Alignment.CenterStart) { leadingContent() } } else { leadingContent() } if (task.projectColor != null) { Box( modifier = GlanceModifier .size(6.dp) .background(parseGlanceHexColor(task.projectColor)) ) {} Spacer(modifier = GlanceModifier.width(4.dp)) } Box( modifier = GlanceModifier .defaultWeight() .clickable(actionStartActivity(detailIntent)) .padding(start = 8.dp), contentAlignment = Alignment.CenterStart ) { ShadowedText( text = task.title, style = TextStyle( color = ColorProvider(titleColor), fontSize = textSize.scaledContentSize(14), fontWeight = textSize.scaledContentWeight(FontWeight.Normal) ), haloColor = palette.haloColor, maxLines = 1 ) } if (task.chainTotal > 0) { ShadowedText( text = "${task.chainPosition}/${task.chainTotal}", style = TextStyle( color = ColorProvider(palette.textMuted), fontSize = textSize.scaledContentSize(11) ), haloColor = palette.haloColor, modifier = GlanceModifier.padding(start = 4.dp) ) } if (task.bucketState == "active") { Box( modifier = GlanceModifier .padding(start = 4.dp) .clickable( actionRunCallback( actionParametersOf(DeferTaskAction.idKey to task.id) ) ) ) { ShadowedText( text = "defer", style = TextStyle( color = ColorProvider(palette.textSecondary), fontSize = textSize.scaledContentSize(11) ), haloColor = palette.haloColor ) } } } } @Composable fun TomorrowSection( items: List, fragments: List, allDayEvents: List, multiDayEvents: List>, zone: ZoneId, textSize: WidgetTextSize, palette: WidgetPalette, hideCheckboxes: Boolean = false ) { // This whole section renders inside a single LazyColumn `item { }` slot // (see DootWidget.kt), which composes to one RemoteViews node -- without // an explicit Column wrapping the divider/header/rows below, they have // no shared vertical-flow container and all stack at the same position // instead of flowing top-to-bottom. Column(modifier = GlanceModifier.fillMaxWidth()) { Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).padding(vertical = 4.dp).background(palette.divider)) {} Row(modifier = GlanceModifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp)) { ShadowedText( "TOMORROW", style = TextStyle( color = ColorProvider(palette.textSecondary), fontSize = textSize.scaledHeaderSize(11), fontWeight = textSize.scaledHeaderWeight(FontWeight.Bold) ), haloColor = palette.haloColor ) } allDayEvents.forEach { AllDayRow(it, textSize, palette = palette, titleColor = palette.textSecondary) } multiDayEvents.forEach { (item, variant) -> AllDayRow(item, textSize, variant, palette, titleColor = palette.textSecondary) } items.forEach { item -> if (item.type == "event") { TimeLabeledEventRow(item, zone, textSize, palette, titleColor = palette.textSecondary) } else { TaskRow( item, textSize, palette, hideCheckboxes, titleColor = palette.textSecondary, gutterWidth = 32.dp ) } } fragments.forEach { frag -> frag.slots.forEach { slot -> TaskRow( slot.task, textSize, palette, hideCheckboxes, titleColor = palette.textSecondary, gutterWidth = 32.dp ) } } } } /** * A time-label + title row for an event shown outside the hourly grid -- * used for tomorrow's events (no grid exists past today) and, since the * 2026-08-05 "past events shouldn't stretch the grid" change, for today's * already-ended events too (same treatment past tasks already got: once * past, it's just a list item, not a grid slot). [titleColor]/[alpha] let * each caller keep its own existing look -- tomorrow's events are * de-emphasized via textSecondary at full opacity; today's past events * keep EventBlock's previous textPrimary-at-50%-alpha treatment. */ @Composable fun TimeLabeledEventRow( event: WidgetItem, zone: ZoneId, textSize: WidgetTextSize, palette: WidgetPalette, titleColor: Color, alpha: Float = 1f ) { 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(calendarViewIntent(event.url))), verticalAlignment = Alignment.CenterVertically ) { ShadowedText( text = timeLabel, style = TextStyle( color = ColorProvider(palette.textMuted.copy(alpha = alpha)), fontSize = textSize.scaledHeaderSize(10), fontWeight = textSize.scaledHeaderWeight(FontWeight.Normal) ), haloColor = palette.haloColor.copy(alpha = palette.haloColor.alpha * alpha), modifier = GlanceModifier.width(32.dp) ) ShadowedText( text = event.title, style = TextStyle( color = ColorProvider(titleColor.copy(alpha = alpha)), fontSize = textSize.scaledContentSize(13), fontWeight = textSize.scaledContentWeight(FontWeight.Normal) ), haloColor = palette.haloColor.copy(alpha = palette.haloColor.alpha * alpha), modifier = GlanceModifier.padding(start = 8.dp), maxLines = 1 ) } } /** * True for a calendar event (never a task -- past tasks are already handled * separately by SlotPacker) whose end time has passed. Matches the "past" * semantics EventBlock used to compute inline before the 2026-08-05 change * that pulls past events out of the grid entirely: an event with no end * time is never past (still ongoing/current, not stale). */ internal fun isPastEvent(item: WidgetItem, now: Instant): Boolean { if (item.type == "task") return false val end = item.end?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false return end < now } private val MOON_PHASES = listOf("🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘") // 🌑🌒🌓🌔🌕🌖🌗🌘 private val KNOWN_NEW_MOON: LocalDate = LocalDate.of(2000, 1, 6) // widely-used reference new moon (18:14 UTC) private const val SYNODIC_MONTH_DAYS = 29.53058867 /** * Today's moon phase, purely computed from the date -- no network call, no * API key, just days-since-a-known-new-moon modulo the synodic month. * Accurate to within about a day, which is the point: a small, honestly-fun * touch for the "TODAY" header, not a navigation instrument. (Requested * 2026-08-06 as "do something fun and surprising" -- picked this because * it's a nice detail widgets basically never have, actually useful if you * sail, and costs nothing to compute or maintain.) */ internal fun moonPhaseEmoji(date: LocalDate): String { val daysSince = ChronoUnit.DAYS.between(KNOWN_NEW_MOON, date).toDouble() val phase = ((daysSince % SYNODIC_MONTH_DAYS) + SYNODIC_MONTH_DAYS) % SYNODIC_MONTH_DAYS val index = ((phase / SYNODIC_MONTH_DAYS) * MOON_PHASES.size).toInt().coerceIn(0, MOON_PHASES.size - 1) return MOON_PHASES[index] } internal fun hourLabel(hour: Int): String = when { hour == 0 -> "12a" hour < 12 -> "${hour}a" hour == 12 -> "12p" else -> "${hour - 12}p" } internal 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) } internal 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) }