From a43cc7b8f007a86742756bd4b67f9ba1c204bbd0 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 06:09:29 +0000 Subject: fix(widget): dedup rapid completeTask taps on the same task CompleteWorker.enqueue used a plain WorkManager.enqueue(), which allows unlimited concurrent OneTimeWorkRequests. A rapid double-tap on the same row (plausible since the checkbox doesn't visually update until the full async round-trip -- complete() -> fetchAndPersist() -> updateAll() -- finishes) could spawn two independent, unordered CompleteWorker runs for the same task, each doing its own fetchAndPersist(); a second worker's fetch started before the first worker's complete() call had actually landed server-side could persist a stale snapshot after the first worker's correct one. Now uses enqueueUniqueWork("complete_$id", KEEP, ...) so a tap on a task that already has a completion in flight is dropped rather than racing a second worker. Different task ids remain independent. --- .../org/terst/doot/widget/work/CompleteWorker.kt | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) (limited to 'android/app/src/main/java/org/terst') diff --git a/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt b/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt index d3017ff..51ad727 100644 --- a/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt +++ b/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt @@ -35,12 +35,31 @@ class CompleteWorker(context: Context, params: WorkerParameters) : const val KEY_ID = "item_id" const val KEY_SOURCE = "item_source" + // enqueueUniqueWork(id, KEEP, ...) instead of a plain enqueue(): a + // rapid double-tap on the same row (plausible since the checkbox + // doesn't visually update until this worker's full async round-trip + // -- complete() -> fetchAndPersist() -> updateAll() -- finishes) + // previously spawned two independent, unordered CompleteWorker runs + // for the same task. Each does its own fetchAndPersist(), so a + // second worker's fetch (started before the first worker's + // complete() call had actually landed server-side) could persist a + // stale snapshot after the first worker's correct one, leaving the + // widget showing outdated data. KEEP means a tap on a task that + // already has a completion in flight is simply dropped -- the first + // request's result (including its fetchAndPersist/updateAll) is what + // takes effect, with no race between two workers touching the same + // task. Different task ids still run independently/concurrently, + // which is fine since they don't share a row. fun enqueue(context: Context, id: String, source: String) { val data = workDataOf(KEY_ID to id, KEY_SOURCE to source) val request = OneTimeWorkRequestBuilder() .setInputData(data) .build() - WorkManager.getInstance(context).enqueue(request) + WorkManager.getInstance(context).enqueueUniqueWork( + "complete_$id", + ExistingWorkPolicy.KEEP, + request + ) } } } -- cgit v1.2.3 From 84252756c687044d73a0ea5cebf8088c7c9ed3e8 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 06:21:40 +0000 Subject: fix(widget): pin all-day calendar events to the top instead of losing them WidgetItem.isAllDay was carried by the client's data model but nothing ever read it. All-day events had no Start at all, so they fell into the same floating-task queue as ordinary untimed tasks; if enough tasks were ahead of one in the queue, SlotPacker could assign it an hour slot past the visible grid range entirely -- not merely unpinned, actually invisible. Server: TimelineItemToWidgetItem now populates Start for all-day CALENDAR EVENTS specifically (their real event date), while leaving undated doot/gtask tasks -- also flagged IsAllDay as a "no specific time" fallback, a different concept -- on the existing nil-Start floating behavior. Client: all-day events are filtered out of the hourly grid/floating-task pipeline entirely, bucketed by day using the new Start date, and rendered in a new pinned AllDayRow section right after the TODAY/TOMORROW headers. --- .../java/org/terst/doot/widget/ui/DootWidget.kt | 60 ++++++++++++++++++--- internal/handlers/widget.go | 25 ++++++--- internal/handlers/widget_test.go | 61 ++++++++++++++++++++++ 3 files changed, 132 insertions(+), 14 deletions(-) (limited to 'android/app/src/main/java/org/terst') 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 d8f1140..7674ddb 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 @@ -63,17 +63,39 @@ class DootWidget : GlanceAppWidget() { fun WidgetRoot(items: List, now: Instant) { val zone = ZoneId.systemDefault() val nowZoned: ZonedDateTime = now.atZone(zone) - val allScheduled = items.filter { it.start != null }.sortedBy { Instant.parse(it.start!!) } + 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 = items.filter { it.start == null } + 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 tomorrowStart = nowZoned.toLocalDate().plusDays(1).atStartOfDay(zone).toInstant() - val tomorrowEnd = tomorrowStart.plus(1, ChronoUnit.DAYS) val tomorrowItems = scheduledEvents .filter { Instant.parse(it.start!!) >= tomorrowStart && Instant.parse(it.start!!) < tomorrowEnd } val tomorrowFrags = fragments @@ -96,16 +118,38 @@ fun WidgetRoot(items: List, now: Instant) { ) } + todayAllDay.forEach { AllDayRow(it) } + for (hour in gridStart..gridEnd) { HourRow(hour, nowZoned, scheduledEvents, fragments, zone) } - if (tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() }) { - TomorrowSection(tomorrowItems, tomorrowFrags, zone) + if (tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() } || tomorrowAllDay.isNotEmpty()) { + TomorrowSection(tomorrowItems, tomorrowFrags, tomorrowAllDay, zone) } } } +@Composable +fun AllDayRow(event: WidgetItem) { + val color = sourceColor(event.source) + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .clickable(actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(event.url.ifEmpty { "https://calendar.google.com" })))), + 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 HourRow( hour: Int, @@ -284,7 +328,7 @@ fun TaskRow(task: WidgetItem) { } @Composable -fun TomorrowSection(items: List, fragments: List, zone: ZoneId) { +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)) { @@ -298,6 +342,8 @@ fun TomorrowSection(items: List, fragments: List, zone ) } + allDayEvents.forEach { AllDayRow(it) } + items.forEach { item -> val isPast = false if (item.type == "event") { diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index 6c3b455..dc10db6 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -52,15 +52,26 @@ func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem { wi.Completable = item.Source == "doot" } - // Only populate Start/End for items with a real time (non-all-day, non-zero) - if !item.IsAllDay && !item.Time.IsZero() { + // Only populate Start/End for items with a real time. All-day CALENDAR + // EVENTS (not undated doot/gtask tasks, which are also flagged IsAllDay + // as a "no specific time" fallback -- see TimelineItem.ComputeDaySection) + // get Start populated too, using their real event date, so the widget + // client can pin them to the top of the correct day's section instead of + // losing them in the floating-task hourly-slot packer, which has no + // concept of "all day" and can push a slot past the visible grid range + // entirely. Tasks keep the existing nil-Start "floating" treatment + // regardless of IsAllDay -- only Start is set for them (never End), and + // only when they have a real time. + if !item.Time.IsZero() && (!item.IsAllDay || item.Type == models.TimelineItemTypeEvent) { t := item.Time wi.Start = &t - if item.EndTime != nil { - wi.End = item.EndTime - } else { - end := item.Time.Add(time.Hour) - wi.End = &end + if !item.IsAllDay { + if item.EndTime != nil { + wi.End = item.EndTime + } else { + end := item.Time.Add(time.Hour) + wi.End = &end + } } } diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go index 850dc07..6d741d2 100644 --- a/internal/handlers/widget_test.go +++ b/internal/handlers/widget_test.go @@ -109,6 +109,67 @@ func TestTimelineItemToWidgetItem_Event(t *testing.T) { } } +// TestTimelineItemToWidgetItem_AllDayEvent proves the 2026-07-12 fix: an +// all-day CALENDAR EVENT (Type == event, IsAllDay == true) must get Start +// populated with its real date -- previously Start was nil for every +// IsAllDay item regardless of type, which meant the widget client's +// hourly-slot packer had no date information to pin all-day events to the +// top of the correct day and they could silently fall outside the visible +// grid range instead. End stays nil since there's no meaningful end time to +// show for an all-day item. +func TestTimelineItemToWidgetItem_AllDayEvent(t *testing.T) { + day := time.Date(2026, 7, 12, 0, 0, 0, 0, time.Local) + item := models.TimelineItem{ + ID: "cal-holiday", + Title: "Company Holiday", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: day, + IsAllDay: true, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.Type != "event" { + t.Errorf("Type: got %q, want %q", wi.Type, "event") + } + if !wi.IsAllDay { + t.Error("expected IsAllDay to be true") + } + if wi.Start == nil { + t.Fatal("all-day event should have non-nil Start (needed to pin it to the correct day)") + } + if !wi.Start.Equal(day) { + t.Errorf("Start = %v, want %v", *wi.Start, day) + } + if wi.End != nil { + t.Error("all-day event should have nil End") + } +} + +// TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior proves the +// same fix does NOT change behavior for undated doot/gtask tasks, which are +// also flagged IsAllDay as a "no specific time" fallback (see +// TimelineItem.ComputeDaySection) but are a different concept from a real +// all-day calendar event -- they must keep the existing nil-Start +// "floating" treatment so the hourly-slot packer still places them. +func TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior(t *testing.T) { + item := models.TimelineItem{ + ID: "undated-task", + Title: "Someday task", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + IsAllDay: true, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.Start != nil { + t.Error("an undated task (IsAllDay as a fallback, not a real all-day event) should still have nil Start") + } +} + func TestHandleWidgetComplete_NonCompletable(t *testing.T) { h := &Handler{} body := `{"id":"x","source":"calendar"}` -- cgit v1.2.3 From e1d93d9de7a8af3d47fefdb797ae36c7bde555aa Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 09:41:26 +0000 Subject: feat(widget): add manual refresh button with loading-state icon --- .../java/org/terst/doot/widget/data/DataStore.kt | 2 ++ .../main/java/org/terst/doot/widget/ui/Actions.kt | 21 +++++++++++++ .../java/org/terst/doot/widget/ui/DootWidget.kt | 34 +++++++++++++++++++--- .../org/terst/doot/widget/work/RefreshWorker.kt | 13 ++++++--- android/app/src/main/res/drawable/ic_refresh.xml | 21 +++++++++++++ .../src/main/res/drawable/ic_refresh_loading.xml | 16 ++++++++++ 6 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_refresh.xml create mode 100644 android/app/src/main/res/drawable/ic_refresh_loading.xml (limited to 'android/app/src/main/java/org/terst') diff --git a/android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt b/android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt index 164e3a2..d2f2cbb 100644 --- a/android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt +++ b/android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt @@ -3,6 +3,7 @@ package org.terst.doot.widget.data import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore @@ -15,4 +16,5 @@ object Keys { val ITEMS_JSON = stringPreferencesKey("items_json") val NOW = stringPreferencesKey("now") val LAST_UPDATED = longPreferencesKey("last_updated") + val IS_REFRESHING = booleanPreferencesKey("is_refreshing") } diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt b/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt index 27a49e6..3a813fd 100644 --- a/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt +++ b/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt @@ -1,10 +1,15 @@ package org.terst.doot.widget.ui import android.content.Context +import androidx.datastore.preferences.core.edit import androidx.glance.GlanceId import androidx.glance.action.ActionParameters import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.updateAll +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.dataStore import org.terst.doot.widget.work.CompleteWorker +import org.terst.doot.widget.work.RefreshWorker class CompleteTaskAction : ActionCallback { override suspend fun onAction( @@ -22,3 +27,19 @@ class CompleteTaskAction : ActionCallback { val sourceKey = ActionParameters.Key("item_source") } } + +// Sets IS_REFRESHING synchronously (before the network round trip) so the +// widget's icon flips to the loading state on the spot -- RefreshWorker +// clears the flag when it finishes, regardless of outcome, so the icon +// never gets stuck. +class RefreshTaskAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + context.dataStore.edit { it[Keys.IS_REFRESHING] = true } + DootWidget().updateAll(context) + RefreshWorker.runOnce(context) + } +} 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 7674ddb..7c84168 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 @@ -47,9 +47,10 @@ class DootWidget : GlanceAppWidget() { 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) + WidgetRoot(items, now, isRefreshing) } } @@ -60,7 +61,7 @@ class DootWidget : GlanceAppWidget() { } @Composable -fun WidgetRoot(items: List, now: Instant) { +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() @@ -107,15 +108,20 @@ fun WidgetRoot(items: List, now: Instant) { .background(Color.Transparent) .padding(horizontal = 8.dp, vertical = 4.dp) ) { - Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 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() ) + RefreshButton(isRefreshing) } todayAllDay.forEach { AllDayRow(it) } @@ -150,6 +156,26 @@ fun AllDayRow(event: WidgetItem) { } } +@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 HourRow( hour: Int, diff --git a/android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt b/android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt index 5c0e89c..3bb8bdd 100644 --- a/android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt +++ b/android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt @@ -1,6 +1,7 @@ package org.terst.doot.widget.work import android.content.Context +import androidx.datastore.preferences.core.edit import androidx.glance.appwidget.updateAll import androidx.work.* import kotlinx.coroutines.flow.first @@ -14,16 +15,20 @@ class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { + val result = performRefresh() + applicationContext.dataStore.edit { it[Keys.IS_REFRESHING] = false } + DootWidget().updateAll(applicationContext) + return result + } + + private suspend fun performRefresh(): Result { val prefs = applicationContext.dataStore.data.first() val url = prefs[Keys.SERVER_URL]?.takeIf { it.isNotBlank() } ?: return Result.failure() val token = prefs[Keys.TOKEN]?.takeIf { it.isNotBlank() } ?: return Result.failure() val repo = WidgetRepository(okhttp3.OkHttpClient(), url, token) return repo.fetchAndPersist(applicationContext).fold( - onSuccess = { - DootWidget().updateAll(applicationContext) - Result.success() - }, + onSuccess = { Result.success() }, onFailure = { Result.retry() } ) } diff --git a/android/app/src/main/res/drawable/ic_refresh.xml b/android/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..4b67528 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_refresh_loading.xml b/android/app/src/main/res/drawable/ic_refresh_loading.xml new file mode 100644 index 0000000..7ad3b2d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_refresh_loading.xml @@ -0,0 +1,16 @@ + + + + + + -- cgit v1.2.3 From 0089863559fbbcc3bc39ecac300d7f08e9b932e1 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 09:51:06 +0000 Subject: feat(widget): color overdue task titles distinctly --- android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt | 1 + android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) (limited to 'android/app/src/main/java/org/terst') diff --git a/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt b/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt index 40fc190..d16fbfd 100644 --- a/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt +++ b/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt @@ -12,6 +12,7 @@ data class WidgetItem( val start: String? = null, // ISO-8601 or null (floating task) val end: String? = null, // ISO-8601 or null @SerialName("is_all_day") val isAllDay: Boolean = false, + @SerialName("is_overdue") val isOverdue: Boolean = false, val url: String = "", val completable: Boolean = false ) 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 7c84168..dabf61f 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 @@ -344,7 +344,9 @@ fun TaskRow(task: WidgetItem) { Text( text = task.title, style = TextStyle( - color = ColorProvider(Color(0xFFDDDDDD.toInt())), + color = ColorProvider( + if (task.isOverdue) Color(0xFFF87171) else Color(0xFFDDDDDD.toInt()) + ), fontSize = 14.sp ), maxLines = 1 -- cgit v1.2.3 From e1e632c1e57c8aa0940e7ff566a1c7ab267625c8 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 10:26:56 +0000 Subject: feat(widget): make the due-date display the reschedule tap target --- .../main/java/org/terst/doot/widget/data/WidgetItem.kt | 1 + .../src/main/java/org/terst/doot/widget/ui/DootWidget.kt | 1 + .../java/org/terst/doot/widget/ui/TaskDetailActivity.kt | 16 +++++++++++++++- 3 files changed, 17 insertions(+), 1 deletion(-) (limited to 'android/app/src/main/java/org/terst') diff --git a/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt b/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt index d16fbfd..ff2ff28 100644 --- a/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt +++ b/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt @@ -13,6 +13,7 @@ data class WidgetItem( val end: String? = null, // ISO-8601 or null @SerialName("is_all_day") val isAllDay: Boolean = false, @SerialName("is_overdue") val isOverdue: Boolean = false, + @SerialName("due_date") val dueDate: String? = null, val url: String = "", val completable: Boolean = false ) 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 dabf61f..a0adf85 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 @@ -294,6 +294,7 @@ fun TaskRow(task: WidgetItem) { 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 diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt b/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt index 0dac1cc..648ad10 100644 --- a/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt +++ b/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt @@ -22,6 +22,8 @@ import org.terst.doot.widget.data.Keys import org.terst.doot.widget.data.WidgetRepository import org.terst.doot.widget.data.dataStore import org.terst.doot.widget.work.CompleteWorker +import java.time.LocalDate +import java.time.format.DateTimeFormatter import java.util.Calendar import java.util.TimeZone @@ -34,6 +36,7 @@ class TaskDetailActivity : ComponentActivity() { val source = intent.getStringExtra(EXTRA_SOURCE) ?: return finish() val title = intent.getStringExtra(EXTRA_TITLE) ?: "" val completable = intent.getBooleanExtra(EXTRA_COMPLETABLE, false) + val dueDate = intent.getStringExtra(EXTRA_DUE_DATE) setContent { MaterialTheme(colorScheme = darkColorScheme()) { @@ -41,6 +44,7 @@ class TaskDetailActivity : ComponentActivity() { title = title, source = source, completable = completable, + dueDate = dueDate, onComplete = { CompleteWorker.enqueue(this, id, source) finish() @@ -69,6 +73,7 @@ class TaskDetailActivity : ComponentActivity() { const val EXTRA_SOURCE = "task_source" const val EXTRA_TITLE = "task_title" const val EXTRA_COMPLETABLE = "task_completable" + const val EXTRA_DUE_DATE = "task_due_date" } } @@ -78,6 +83,7 @@ fun TaskDetailSheet( title: String, source: String, completable: Boolean, + dueDate: String?, onComplete: () -> Unit, onReschedule: (String) -> Unit, onDismiss: () -> Unit @@ -167,10 +173,18 @@ fun TaskDetailSheet( colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) ) { - Text("Reschedule", fontSize = 15.sp) + Text(formatDueDateLabel(dueDate), fontSize = 15.sp) } } Spacer(Modifier.height(20.dp)) } } } + +private fun formatDueDateLabel(dueDate: String?): String { + if (dueDate == null) return "No due date · tap to schedule" + return runCatching { + val date = LocalDate.parse(dueDate.substring(0, 10)) + "Due " + date.format(DateTimeFormatter.ofPattern("MMM d")) + }.getOrDefault("No due date · tap to schedule") +} -- cgit v1.2.3 From 029902606396cf5ec583e187e7adc576c4aafb71 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 10:38:59 +0000 Subject: feat(widget): add quick-add button and entry sheet --- android/app/src/main/AndroidManifest.xml | 6 ++ .../org/terst/doot/widget/data/WidgetRepository.kt | 26 +++++ .../java/org/terst/doot/widget/ui/DootWidget.kt | 23 +++++ .../org/terst/doot/widget/ui/QuickAddActivity.kt | 114 +++++++++++++++++++++ android/app/src/main/res/drawable/ic_add.xml | 13 +++ 5 files changed, 182 insertions(+) create mode 100644 android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt create mode 100644 android/app/src/main/res/drawable/ic_add.xml (limited to 'android/app/src/main/java/org/terst') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3fc5603..cae6ffb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,12 @@ android:theme="@style/Theme.TaskDetail" android:exported="false" /> + + + = + withContext(Dispatchers.IO) { + val body = json.encodeToString(WidgetAddRequest(title)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/add") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } } 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 a0adf85..e326537 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 @@ -121,6 +121,8 @@ fun WidgetRoot(items: List, now: Instant, isRefreshing: Boolean) { ), modifier = GlanceModifier.defaultWeight() ) + QuickAddButton() + Spacer(modifier = GlanceModifier.width(4.dp)) RefreshButton(isRefreshing) } @@ -176,6 +178,27 @@ fun RefreshButton(isRefreshing: Boolean) { } } +@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, diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt b/android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt new file mode 100644 index 0000000..52933ea --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt @@ -0,0 +1,114 @@ +package org.terst.doot.widget.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.appwidget.updateAll +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore + +class QuickAddActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + QuickAddSheet( + onAdd = { title -> + lifecycleScope.launch { + val prefs = this@QuickAddActivity.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@launch + val token = prefs[Keys.TOKEN] ?: return@launch + val repo = WidgetRepository(OkHttpClient(), url, token) + repo.addTask(title).onSuccess { + repo.fetchAndPersist(this@QuickAddActivity) + DootWidget().updateAll(this@QuickAddActivity) + finish() + } + } + }, + onDismiss = ::finish + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuickAddSheet( + onAdd: (String) -> Unit, + onDismiss: () -> Unit +) { + var title by remember { mutableStateOf("") } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color(0xFF1E293B), + tonalElevation = 0.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .navigationBarsPadding() + ) { + Text( + text = "Quick Add", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + placeholder = { Text("Task title") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color.White.copy(alpha = 0.3f) + ) + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { onAdd(title.trim()) }, + enabled = title.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) + ) { + Text("Add", fontSize = 15.sp) + } + Spacer(Modifier.height(20.dp)) + } + } +} diff --git a/android/app/src/main/res/drawable/ic_add.xml b/android/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..a96533f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,13 @@ + + + + -- cgit v1.2.3 From b1c76cdf30b7d491f3791588c8aa8c66ee5a0950 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 10:41:12 +0000 Subject: fix(widget): add IME handling to quick-add's keyboard-covering input field --- android/app/src/main/AndroidManifest.xml | 3 ++- android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) (limited to 'android/app/src/main/java/org/terst') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cae6ffb..6d35ff7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -20,7 +20,8 @@ + android:exported="false" + android:windowSoftInputMode="adjustResize" /> Date: Sun, 12 Jul 2026 11:18:37 +0000 Subject: feat(widget): add event detail popup showing recurrence schedule Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD --- android/app/src/main/AndroidManifest.xml | 6 + .../java/org/terst/doot/widget/data/WidgetItem.kt | 3 +- .../org/terst/doot/widget/data/WidgetRepository.kt | 20 +++ .../java/org/terst/doot/widget/ui/DootWidget.kt | 31 ++++- .../terst/doot/widget/ui/EventDetailActivity.kt | 145 +++++++++++++++++++++ 5 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt (limited to 'android/app/src/main/java/org/terst') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6d35ff7..4b58442 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,12 @@ android:exported="false" android:windowSoftInputMode="adjustResize" /> + + + = + withContext(Dispatchers.IO) { + val encodedId = java.net.URLEncoder.encode(recurringEventId, "UTF-8") + val request = Request.Builder() + .url("$serverUrl/api/widget/recurrence?recurring_event_id=$encodedId") + .header("Authorization", "Bearer $token") + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + val body = checkNotNull(response.body?.string()) { "Empty body" } + val parsed = json.decodeFromString(body) + parsed.recurrence + } + } } 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 e326537..2dbc96b 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 @@ -2,7 +2,6 @@ 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 @@ -140,12 +139,20 @@ fun WidgetRoot(items: List, now: Instant, isRefreshing: Boolean) { @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(Intent(Intent.ACTION_VIEW, Uri.parse(event.url.ifEmpty { "https://calendar.google.com" })))), + .clickable(actionStartActivity(detailIntent)), verticalAlignment = Alignment.CenterVertically ) { Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {} @@ -260,13 +267,21 @@ fun HourRow( @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(Intent(Intent.ACTION_VIEW, Uri.parse(event.url.ifEmpty { "https://calendar.google.com" })))), + .clickable(actionStartActivity(detailIntent)), verticalAlignment = Alignment.CenterVertically ) { Box( @@ -412,6 +427,14 @@ fun TomorrowSection(items: List, fragments: List, allD @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) @@ -421,7 +444,7 @@ fun TomorrowEventRow(event: WidgetItem, zone: ZoneId) { modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 3.dp) - .clickable(actionStartActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(event.url.ifEmpty { "https://calendar.google.com" })))), + .clickable(actionStartActivity(detailIntent)), verticalAlignment = Alignment.CenterVertically ) { Text( diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt b/android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt new file mode 100644 index 0000000..b8423ce --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt @@ -0,0 +1,145 @@ +package org.terst.doot.widget.ui + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore + +class EventDetailActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val title = intent.getStringExtra(EXTRA_TITLE) ?: "" + val source = intent.getStringExtra(EXTRA_SOURCE) ?: "calendar" + val url = intent.getStringExtra(EXTRA_URL) ?: "" + val recurringEventId = intent.getStringExtra(EXTRA_RECURRING_EVENT_ID) + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + EventDetailSheet( + title = title, + source = source, + recurringEventId = recurringEventId, + onOpenCalendar = { + startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url.ifEmpty { "https://calendar.google.com" })) + ) + finish() + }, + onDismiss = ::finish + ) + } + } + } + + companion object { + const val EXTRA_TITLE = "event_title" + const val EXTRA_SOURCE = "event_source" + const val EXTRA_URL = "event_url" + const val EXTRA_RECURRING_EVENT_ID = "event_recurring_id" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventDetailSheet( + title: String, + source: String, + recurringEventId: String?, + onOpenCalendar: () -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + var recurrenceText by remember { mutableStateOf(null) } + + LaunchedEffect(recurringEventId) { + if (recurringEventId == null) return@LaunchedEffect + recurrenceText = "Loading…" + val prefs = context.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@LaunchedEffect + val token = prefs[Keys.TOKEN] ?: return@LaunchedEffect + val repo = WidgetRepository(OkHttpClient(), url, token) + repo.getRecurrence(recurringEventId).onSuccess { text -> + recurrenceText = text + }.onFailure { + recurrenceText = null + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color(0xFF1E293B), + tonalElevation = 0.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .navigationBarsPadding() + ) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .background(sourceColor(source), RoundedCornerShape(5.dp)) + ) + Spacer(Modifier.width(10.dp)) + Text( + text = title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + modifier = Modifier.weight(1f) + ) + } + recurrenceText?.let { text -> + Spacer(Modifier.height(8.dp)) + Text( + text = text, + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.6f) + ) + } + Spacer(Modifier.height(20.dp)) + OutlinedButton( + onClick = onOpenCalendar, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + ) { + Text("Open in Calendar", fontSize = 15.sp) + } + Spacer(Modifier.height(20.dp)) + } + } +} -- cgit v1.2.3