From f48eae22ec7510e662e0ad6a3fa02781e951af4d Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 15 Jul 2026 09:52:56 +0000 Subject: refactor(widget): split DootWidget.kt by responsibility DootWidget.kt (582 lines) mixed the GlanceAppWidget entry point/root composable, all the row-rendering composables, and multi-day-event detection logic in one file. Split into: - MultiDayEvents.kt: MultiDayVariant, effectiveEndDay, isMultiDayEvent, multiDayVariant, multiDayLabel, timeSuffix. - WidgetRows.kt: sourceColor, calendarViewIntent, and all row/section composables (AllDayRow, RefreshButton, QuickAddButton, HourRow, EventBlock, TaskFragmentBlock, TaskRow, TomorrowSection, TomorrowEventRow), plus hourLabel/calcGridStart/calcGridEnd. - DootWidget.kt: just the GlanceAppWidget class and WidgetRoot, now 145 lines. All same package (org.terst.doot.widget.ui), so no import changes needed for cross-file references. Pure move, no behavior change -- full unit test suite (including DootWidgetGridTest/ DootWidgetMultiDayTest, which call the internal functions directly) passes unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD --- .../java/org/terst/doot/widget/ui/DootWidget.kt | 437 --------------------- .../org/terst/doot/widget/ui/MultiDayEvents.kt | 61 +++ .../java/org/terst/doot/widget/ui/WidgetRows.kt | 398 +++++++++++++++++++ 3 files changed, 459 insertions(+), 437 deletions(-) create mode 100644 android/app/src/main/java/org/terst/doot/widget/ui/MultiDayEvents.kt create mode 100644 android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt (limited to 'android') diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt index fd8481d..4a134b8 100644 --- a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt +++ b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt @@ -1,18 +1,12 @@ package org.terst.doot.widget.ui import android.content.Context -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.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.lazy.LazyColumn import androidx.glance.appwidget.provideContent import androidx.glance.layout.* @@ -29,63 +23,6 @@ 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) -} - -/** 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) - } - -enum class MultiDayVariant { NONE, STARTS, ENDS, SPANS } - -/** - * The last calendar day an item's occurrence actually covers. Google - * Calendar's all-day end date is EXCLUSIVE -- a single-day all-day event on - * July 13 has end=July14 00:00 (one day past its own day; mirrors the same - * convention already handled server-side, see effectiveEndDay in - * internal/handlers/timeline.go) -- so for an all-day item this subtracts - * one day to get the real last day. Timed events' end is already the real - * end instant, so no adjustment is needed there. - */ -private fun effectiveEndDay(end: Instant, zone: ZoneId, isAllDay: Boolean): LocalDate { - val day = end.atZone(zone).toLocalDate() - return if (isAllDay) day.minusDays(1) else day -} - -/** True for a calendar event whose start and (adjusted) end fall on different calendar days. */ -internal fun isMultiDayEvent(item: WidgetItem, zone: ZoneId): Boolean { - if (item.type != "event") return false - val start = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false - val end = item.end?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false - val startDay = start.atZone(zone).toLocalDate() - val endDay = effectiveEndDay(end, zone, item.isAllDay) - return endDay.isAfter(startDay) -} - -/** - * How a multi-day item should render on renderDay. Caller must have already - * confirmed isMultiDayEvent(item) so item.start/item.end are non-null. - */ -internal fun multiDayVariant(item: WidgetItem, renderDay: LocalDate, zone: ZoneId): MultiDayVariant { - val start = Instant.parse(item.start!!).atZone(zone).toLocalDate() - val end = effectiveEndDay(Instant.parse(item.end!!), zone, item.isAllDay) - return when { - renderDay.isEqual(start) -> MultiDayVariant.STARTS - renderDay.isEqual(end) -> MultiDayVariant.ENDS - renderDay.isAfter(start) && renderDay.isBefore(end) -> MultiDayVariant.SPANS - else -> MultiDayVariant.NONE - } -} - internal val json = Json { ignoreUnknownKeys = true } class DootWidget : GlanceAppWidget() { @@ -219,377 +156,3 @@ fun WidgetRoot(items: List, now: Instant, isRefreshing: Boolean, tex } } } - -@Composable -fun AllDayRow(event: WidgetItem, textSize: WidgetTextSize, variant: MultiDayVariant = MultiDayVariant.NONE) { - val color = sourceColor(event.source) - 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 color bar lines up - // with EventBlock's bar in the grid below it, instead of sitting flush left. - Spacer(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.9f)), - fontSize = textSize.scaledContentSize(13), - fontWeight = textSize.scaledContentWeight(FontWeight.Medium) - ), - modifier = GlanceModifier.padding(start = 8.dp), - maxLines = 1 - ) - if (label.isNotEmpty()) { - Text( - // Full opacity, matching EventBlock's upcoming (non-past) events -- - // the label is time-relevant info, not secondary chrome. - text = label, - style = TextStyle( - color = ColorProvider(Color.White.copy(alpha = 1f)), - fontSize = textSize.scaledContentSize(13), - fontWeight = textSize.scaledContentWeight(FontWeight.Medium) - ), - modifier = GlanceModifier.padding(start = 4.dp), - maxLines = 1 - ) - } - } -} - -/** "" for a plain all-day row or a "spans"-through day; " (starts/ends HH:MM)" otherwise. */ -private fun multiDayLabel(event: WidgetItem, variant: MultiDayVariant): String = when (variant) { - MultiDayVariant.STARTS -> event.start?.let { timeSuffix("starts", it) } ?: "" - MultiDayVariant.ENDS -> event.end?.let { timeSuffix("ends", it) } ?: "" - else -> "" -} - -private fun timeSuffix(verb: String, iso: String): String { - val t = Instant.parse(iso).atZone(ZoneId.systemDefault()) - if (t.hour == 0 && t.minute == 0) return "" - val minutePart = if (t.minute > 0) t.minute.toString().padStart(2, '0') else "" - return " ($verb ${hourLabel(t.hour)}$minutePart)" -} - -@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, - textSize: WidgetTextSize -) { - 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(0x80FFFFFF)), - fontSize = textSize.scaledHeaderSize(10), - fontWeight = textSize.scaledHeaderWeight(FontWeight.Normal) - ), - 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, textSize) - } - - fragsHere.forEach { frag -> - TaskFragmentBlock(frag, textSize) - } - } - } -} - -@Composable -fun EventBlock(event: WidgetItem, isPast: Boolean, textSize: WidgetTextSize) { - val color = sourceColor(event.source) - val alpha = if (isPast) 0.5f else 1f - Row( - modifier = GlanceModifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable(actionStartActivity(calendarViewIntent(event.url))), - 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 = textSize.scaledContentSize(14), - fontWeight = textSize.scaledContentWeight(FontWeight.Normal) - ), - modifier = GlanceModifier.padding(start = 8.dp), - maxLines = 1 - ) - } -} - -@Composable -fun TaskFragmentBlock(fragment: TaskFragment, textSize: WidgetTextSize) { - Column( - modifier = GlanceModifier - .fillMaxWidth() - .padding(vertical = 2.dp) - ) { - fragment.slots.forEach { slot -> - TaskRow(slot.task, textSize) - } - } -} - -@Composable -fun TaskRow(task: WidgetItem, textSize: WidgetTextSize) { - 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(Color.White), - fontSize = textSize.scaledContentSize(14), - fontWeight = textSize.scaledContentWeight(FontWeight.Normal) - ), - maxLines = 1 - ) - } - } -} - -@Composable -fun TomorrowSection( - items: List, - fragments: List, - allDayEvents: List, - multiDayEvents: List>, - zone: ZoneId, - textSize: WidgetTextSize -) { - 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(0x99FFFFFF)), - fontSize = textSize.scaledHeaderSize(11), - fontWeight = textSize.scaledHeaderWeight(FontWeight.Bold) - ) - ) - } - - allDayEvents.forEach { AllDayRow(it, textSize) } - multiDayEvents.forEach { (item, variant) -> AllDayRow(item, textSize, variant) } - - items.forEach { item -> - val isPast = false - if (item.type == "event") { - TomorrowEventRow(item, zone, textSize) - } else { - TaskRow(item, textSize) - } - } - - fragments.forEach { frag -> - frag.slots.forEach { slot -> TaskRow(slot.task, textSize) } - } -} - -@Composable -fun TomorrowEventRow(event: WidgetItem, zone: ZoneId, textSize: WidgetTextSize) { - 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(calendarViewIntent(event.url))), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = timeLabel, - style = TextStyle( - color = ColorProvider(Color(0x80FFFFFF)), - fontSize = textSize.scaledHeaderSize(10), - fontWeight = textSize.scaledHeaderWeight(FontWeight.Normal) - ), - 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 = textSize.scaledContentSize(13), - fontWeight = textSize.scaledContentWeight(FontWeight.Normal) - ), - 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" -} - -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) -} diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/MultiDayEvents.kt b/android/app/src/main/java/org/terst/doot/widget/ui/MultiDayEvents.kt new file mode 100644 index 0000000..a8f4659 --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/ui/MultiDayEvents.kt @@ -0,0 +1,61 @@ +package org.terst.doot.widget.ui + +import org.terst.doot.widget.data.* +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +enum class MultiDayVariant { NONE, STARTS, ENDS, SPANS } + +/** + * The last calendar day an item's occurrence actually covers. Google + * Calendar's all-day end date is EXCLUSIVE -- a single-day all-day event on + * July 13 has end=July14 00:00 (one day past its own day; mirrors the same + * convention already handled server-side, see effectiveEndDay in + * internal/handlers/timeline.go) -- so for an all-day item this subtracts + * one day to get the real last day. Timed events' end is already the real + * end instant, so no adjustment is needed there. + */ +private fun effectiveEndDay(end: Instant, zone: ZoneId, isAllDay: Boolean): LocalDate { + val day = end.atZone(zone).toLocalDate() + return if (isAllDay) day.minusDays(1) else day +} + +/** True for a calendar event whose start and (adjusted) end fall on different calendar days. */ +internal fun isMultiDayEvent(item: WidgetItem, zone: ZoneId): Boolean { + if (item.type != "event") return false + val start = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false + val end = item.end?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false + val startDay = start.atZone(zone).toLocalDate() + val endDay = effectiveEndDay(end, zone, item.isAllDay) + return endDay.isAfter(startDay) +} + +/** + * How a multi-day item should render on renderDay. Caller must have already + * confirmed isMultiDayEvent(item) so item.start/item.end are non-null. + */ +internal fun multiDayVariant(item: WidgetItem, renderDay: LocalDate, zone: ZoneId): MultiDayVariant { + val start = Instant.parse(item.start!!).atZone(zone).toLocalDate() + val end = effectiveEndDay(Instant.parse(item.end!!), zone, item.isAllDay) + return when { + renderDay.isEqual(start) -> MultiDayVariant.STARTS + renderDay.isEqual(end) -> MultiDayVariant.ENDS + renderDay.isAfter(start) && renderDay.isBefore(end) -> MultiDayVariant.SPANS + else -> MultiDayVariant.NONE + } +} + +/** "" for a plain all-day row or a "spans"-through day; " (starts/ends HH:MM)" otherwise. */ +internal fun multiDayLabel(event: WidgetItem, variant: MultiDayVariant): String = when (variant) { + MultiDayVariant.STARTS -> event.start?.let { timeSuffix("starts", it) } ?: "" + MultiDayVariant.ENDS -> event.end?.let { timeSuffix("ends", it) } ?: "" + else -> "" +} + +private fun timeSuffix(verb: String, iso: String): String { + val t = Instant.parse(iso).atZone(ZoneId.systemDefault()) + if (t.hour == 0 && t.minute == 0) return "" + val minutePart = if (t.minute > 0) t.minute.toString().padStart(2, '0') else "" + return " ($verb ${hourLabel(t.hour)}$minutePart)" +} diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt b/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt new file mode 100644 index 0000000..084b277 --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt @@ -0,0 +1,398 @@ +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.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.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) +} + +/** 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) { + val color = sourceColor(event.source) + 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 color bar lines up + // with EventBlock's bar in the grid below it, instead of sitting flush left. + Spacer(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.9f)), + fontSize = textSize.scaledContentSize(13), + fontWeight = textSize.scaledContentWeight(FontWeight.Medium) + ), + modifier = GlanceModifier.padding(start = 8.dp), + maxLines = 1 + ) + if (label.isNotEmpty()) { + Text( + // Full opacity, matching EventBlock's upcoming (non-past) events -- + // the label is time-relevant info, not secondary chrome. + text = label, + style = TextStyle( + color = ColorProvider(Color.White.copy(alpha = 1f)), + fontSize = textSize.scaledContentSize(13), + fontWeight = textSize.scaledContentWeight(FontWeight.Medium) + ), + modifier = GlanceModifier.padding(start = 4.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, + textSize: WidgetTextSize +) { + 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(0x80FFFFFF)), + fontSize = textSize.scaledHeaderSize(10), + fontWeight = textSize.scaledHeaderWeight(FontWeight.Normal) + ), + 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, textSize) + } + + fragsHere.forEach { frag -> + TaskFragmentBlock(frag, textSize) + } + } + } +} + +@Composable +fun EventBlock(event: WidgetItem, isPast: Boolean, textSize: WidgetTextSize) { + val color = sourceColor(event.source) + val alpha = if (isPast) 0.5f else 1f + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(actionStartActivity(calendarViewIntent(event.url))), + 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 = textSize.scaledContentSize(14), + fontWeight = textSize.scaledContentWeight(FontWeight.Normal) + ), + modifier = GlanceModifier.padding(start = 8.dp), + maxLines = 1 + ) + } +} + +@Composable +fun TaskFragmentBlock(fragment: TaskFragment, textSize: WidgetTextSize) { + Column( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) { + fragment.slots.forEach { slot -> + TaskRow(slot.task, textSize) + } + } +} + +@Composable +fun TaskRow(task: WidgetItem, textSize: WidgetTextSize) { + 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(Color.White), + fontSize = textSize.scaledContentSize(14), + fontWeight = textSize.scaledContentWeight(FontWeight.Normal) + ), + maxLines = 1 + ) + } + } +} + +@Composable +fun TomorrowSection( + items: List, + fragments: List, + allDayEvents: List, + multiDayEvents: List>, + zone: ZoneId, + textSize: WidgetTextSize +) { + 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(0x99FFFFFF)), + fontSize = textSize.scaledHeaderSize(11), + fontWeight = textSize.scaledHeaderWeight(FontWeight.Bold) + ) + ) + } + + allDayEvents.forEach { AllDayRow(it, textSize) } + multiDayEvents.forEach { (item, variant) -> AllDayRow(item, textSize, variant) } + + items.forEach { item -> + val isPast = false + if (item.type == "event") { + TomorrowEventRow(item, zone, textSize) + } else { + TaskRow(item, textSize) + } + } + + fragments.forEach { frag -> + frag.slots.forEach { slot -> TaskRow(slot.task, textSize) } + } +} + +@Composable +fun TomorrowEventRow(event: WidgetItem, zone: ZoneId, textSize: WidgetTextSize) { + 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(calendarViewIntent(event.url))), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = timeLabel, + style = TextStyle( + color = ColorProvider(Color(0x80FFFFFF)), + fontSize = textSize.scaledHeaderSize(10), + fontWeight = textSize.scaledHeaderWeight(FontWeight.Normal) + ), + 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 = textSize.scaledContentSize(13), + fontWeight = textSize.scaledContentWeight(FontWeight.Normal) + ), + modifier = GlanceModifier.padding(start = 8.dp), + maxLines = 1 + ) + } +} + +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) +} -- cgit v1.2.3