summaryrefslogtreecommitdiff
path: root/android/app/src/main/java/org/terst/doot
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src/main/java/org/terst/doot')
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt437
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/MultiDayEvents.kt61
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt398
3 files changed, 459 insertions, 437 deletions
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<WidgetItem>, 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<RefreshTaskAction>()),
- 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<WidgetItem>,
- fragments: List<TaskFragment>,
- @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<CompleteTaskAction>(
- 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<WidgetItem>,
- fragments: List<TaskFragment>,
- allDayEvents: List<WidgetItem>,
- multiDayEvents: List<Pair<WidgetItem, MultiDayVariant>>,
- 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<WidgetItem>, 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<WidgetItem>, 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<RefreshTaskAction>()),
+ 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<WidgetItem>,
+ fragments: List<TaskFragment>,
+ @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<CompleteTaskAction>(
+ 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<WidgetItem>,
+ fragments: List<TaskFragment>,
+ allDayEvents: List<WidgetItem>,
+ multiDayEvents: List<Pair<WidgetItem, MultiDayVariant>>,
+ 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<WidgetItem>, 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<WidgetItem>, 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)
+}