summaryrefslogtreecommitdiff
path: root/android/app/src/main/java/org/terst/doot/widget
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-13 07:11:04 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-13 07:11:04 +0000
commit44abf42ed45aa8f285e7ce031cbb9ef1ade667ea (patch)
treedbc21cadc2750c6e39e7f7613be0b3f73790c445 /android/app/src/main/java/org/terst/doot/widget
parent8310f802dd9fc6ef5dff0be7f640f79c5b39987f (diff)
parent4126fe4f56a6eb9703a084d4793a597f37bf2867 (diff)
Merge github/master: reconcile with parallel widget work
Another session pushed 27 commits in parallel covering quick-add, event detail popups, recurrence display, overdue badges, a manual refresh button, and its own fix for the same overdue-tasks bug (via a separate GetOverdueNativeTasks fetch folded into BuildTimeline, rather than widening GetNativeTasksByDateRange's bound directly). Reconciled rather than blindly taking one side: - Reverted GetNativeTasksByDateRange to its original bounded query and kept upstream's GetOverdueNativeTasks + BuildTimeline fold-in as the sole overdue mechanism for native tasks, to avoid double-counting overdue items (my widened query + their separate fetch would have both returned them). Re-pointed the regression test at the now-correct contract and added a store-level test for GetOverdueNativeTasks directly. - Kept my GetGoogleTasksByDateRange fix as-is (single unbounded query) -- upstream never touched Google Tasks overdue handling, so there's no duplication risk there. - Rewove WidgetRoot's LazyColumn structure (added for scrolling) around upstream's new header buttons, pinned all-day event rows, and the enhanced TomorrowSection, none of which were written LazyColumn-aware since that work landed on this side only. - Combined both sides' additions to TaskDetailActivity/TaskDetailSheet (description-edit detail popup + due-date reschedule label) and WidgetRepository/Actions (optimistic local removal + refresh button wiring) -- these were independent, non-overlapping features that both needed to survive. - Renumbered the migration collision: both sides independently added a migration numbered 022. Card-description was already applied to the live production DB under that filename earlier this session (migrations are tracked by filename), so it keeps 022; the recurring-event-id migration, never deployed under any name here, moves to 023. Verified: go build clean, full test suite passes (only the two pre-existing agent-handler failures and the pre-existing models package build error remain, both confirmed unrelated via git stash before this session began), and a dry run against a copy of the live production database applies both migrations cleanly with no re-run conflicts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'android/app/src/main/java/org/terst/doot/widget')
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt2
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt5
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt46
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt20
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt176
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt145
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt115
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt16
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt21
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt13
10 files changed, 527 insertions, 32 deletions
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/data/WidgetItem.kt b/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt
index 4666dd9..fee15e1 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,8 +12,11 @@ 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,
+ @SerialName("due_date") val dueDate: String? = null,
val url: String = "",
- val completable: Boolean = false
+ val completable: Boolean = false,
+ @SerialName("recurring_event_id") val recurringEventId: String? = null
)
@Serializable
diff --git a/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt b/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt
index b9f52ce..3574eb5 100644
--- a/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt
@@ -14,9 +14,14 @@ import okhttp3.RequestBody.Companion.toRequestBody
private val json = Json { ignoreUnknownKeys = true }
-@Serializable
private data class UpdateDescriptionRequest(val id: String, val source: String, val description: String)
+@Serializable
+private data class WidgetAddRequest(val title: String)
+
+@Serializable
+private data class RecurrenceResponse(val recurrence: String)
+
/**
* Optimistically drops an item from the cached list so the widget can update instantly on
* completion, ahead of the authoritative refresh a worker performs once the API call succeeds.
@@ -121,4 +126,43 @@ class WidgetRepository(
check(response.isSuccessful) { "HTTP ${response.code}" }
}
}
+
+ /**
+ * POSTs a new task title to /api/widget/add. Uses proper JSON encoding
+ * (not manual string interpolation like reschedule/complete above) because
+ * the title is arbitrary user text that could contain characters that
+ * break hand-built JSON -- id/source above are safe because they're
+ * internal identifiers, never user-typed free text.
+ */
+ suspend fun addTask(title: String): Result<Unit> =
+ 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}" }
+ }
+ }
+
+ /** GETs the formatted recurrence schedule for a recurring event. */
+ suspend fun getRecurrence(recurringEventId: String): Result<String> =
+ 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<RecurrenceResponse>(body)
+ parsed.recurrence
+ }
+ }
}
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 f81815f..c6dcae9 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,12 +1,16 @@
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.data.removeWidgetItemLocally
import org.terst.doot.widget.work.CompleteWorker
+import org.terst.doot.widget.work.RefreshWorker
class CompleteTaskAction : ActionCallback {
override suspend fun onAction(
@@ -28,3 +32,19 @@ class CompleteTaskAction : ActionCallback {
val sourceKey = ActionParameters.Key<String>("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 1575350..ae749fb 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
@@ -48,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)
}
}
@@ -61,26 +61,47 @@ class DootWidget : GlanceAppWidget() {
}
@Composable
-fun WidgetRoot(items: List<WidgetItem>, now: Instant) {
+fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean) {
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
.filter { it.startTime >= tomorrowStart && it.startTime < tomorrowEnd }
- val showTomorrow = tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() }
- val tomorrowTaskSlots = tomorrowFrags.flatMap { it.slots }
+ val showTomorrow = tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() } || tomorrowAllDay.isNotEmpty()
// Glance's LazyColumn (backed by a RemoteViews ListView) is what actually scrolls in an
// app widget — a plain Column clips its content to the widget's current height instead.
@@ -91,38 +112,111 @@ fun WidgetRoot(items: List<WidgetItem>, now: Instant) {
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
item {
- 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()
)
+ QuickAddButton()
+ Spacer(modifier = GlanceModifier.width(4.dp))
+ RefreshButton(isRefreshing)
}
}
+ items(count = todayAllDay.size) { index ->
+ AllDayRow(todayAllDay[index])
+ }
+
items(count = gridEnd - gridStart + 1) { index ->
HourRow(gridStart + index, nowZoned, scheduledEvents, fragments, zone)
}
if (showTomorrow) {
- item { TomorrowHeader() }
-
- items(count = tomorrowItems.size) { index ->
- val item = tomorrowItems[index]
- if (item.type == "event") TomorrowEventRow(item, zone) else TaskRow(item)
- }
-
- items(count = tomorrowTaskSlots.size) { index ->
- TaskRow(tomorrowTaskSlots[index].task)
+ item {
+ TomorrowSection(tomorrowItems, tomorrowFrags, tomorrowAllDay, zone)
}
}
}
}
@Composable
+fun AllDayRow(event: WidgetItem) {
+ val context = LocalContext.current
+ val detailIntent = Intent(context, EventDetailActivity::class.java).apply {
+ putExtra(EventDetailActivity.EXTRA_TITLE, event.title)
+ putExtra(EventDetailActivity.EXTRA_SOURCE, event.source)
+ putExtra(EventDetailActivity.EXTRA_URL, event.url)
+ putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val color = sourceColor(event.source)
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 3.dp)
+ .clickable(actionStartActivity(detailIntent)),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {}
+ Text(
+ text = event.title,
+ style = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.9f)), fontSize = 13.sp, fontWeight = FontWeight.Medium),
+ modifier = GlanceModifier.padding(start = 8.dp),
+ maxLines = 1
+ )
+ }
+}
+
+@Composable
+fun RefreshButton(isRefreshing: Boolean) {
+ Box(
+ modifier = GlanceModifier
+ .size(24.dp)
+ .clickable(actionRunCallback<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,
@@ -183,13 +277,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(
@@ -240,6 +342,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
@@ -290,7 +393,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
@@ -300,7 +405,7 @@ fun TaskRow(task: WidgetItem) {
}
@Composable
-fun TomorrowHeader() {
+fun TomorrowSection(items: List<WidgetItem>, fragments: List<TaskFragment>, allDayEvents: List<WidgetItem>, 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)) {
@@ -313,10 +418,33 @@ fun TomorrowHeader() {
)
)
}
+
+ allDayEvents.forEach { AllDayRow(it) }
+
+ items.forEach { item ->
+ val isPast = false
+ if (item.type == "event") {
+ TomorrowEventRow(item, zone)
+ } else {
+ TaskRow(item)
+ }
+ }
+
+ fragments.forEach { frag ->
+ frag.slots.forEach { slot -> TaskRow(slot.task) }
+ }
}
@Composable
fun TomorrowEventRow(event: WidgetItem, zone: ZoneId) {
+ val context = LocalContext.current
+ val detailIntent = Intent(context, EventDetailActivity::class.java).apply {
+ putExtra(EventDetailActivity.EXTRA_TITLE, event.title)
+ putExtra(EventDetailActivity.EXTRA_SOURCE, event.source)
+ putExtra(EventDetailActivity.EXTRA_URL, event.url)
+ putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
val color = sourceColor(event.source)
val timeLabel = event.start?.let {
val t = Instant.parse(it).atZone(zone)
@@ -326,7 +454,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<String?>(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))
+ }
+ }
+}
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..bb6bad0
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt
@@ -0,0 +1,115 @@
+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()
+ .imePadding()
+ ) {
+ 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/java/org/terst/doot/widget/ui/TaskDetailActivity.kt b/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt
index d14386c..3aeafc5 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
@@ -24,6 +24,8 @@ import org.terst.doot.widget.data.WidgetRepository
import org.terst.doot.widget.data.dataStore
import org.terst.doot.widget.data.removeWidgetItemLocally
import org.terst.doot.widget.work.CompleteWorker
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.TimeZone
@@ -36,6 +38,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()) {
@@ -56,6 +59,7 @@ class TaskDetailActivity : ComponentActivity() {
source = source,
completable = completable,
detail = detail,
+ dueDate = dueDate,
onComplete = {
lifecycleScope.launch {
removeWidgetItemLocally(this@TaskDetailActivity, id, source)
@@ -101,6 +105,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"
}
}
@@ -114,6 +119,7 @@ fun TaskDetailSheet(
source: String,
completable: Boolean,
detail: TaskDetail?,
+ dueDate: String?,
onComplete: () -> Unit,
onReschedule: (String) -> Unit,
onSaveDescription: (String) -> Unit,
@@ -238,10 +244,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")
+}
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<CompleteWorker>()
.setInputData(data)
.build()
- WorkManager.getInstance(context).enqueue(request)
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ "complete_$id",
+ ExistingWorkPolicy.KEEP,
+ request
+ )
}
}
}
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() }
)
}