From 8310f802dd9fc6ef5dff0be7f640f79c5b39987f Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 13 Jul 2026 06:54:13 +0000 Subject: feat(widget): editable task details, scrollable list, and overdue-task fixes - Add description editing to the widget's task detail popup for doot/gtasks/trello, backed by new GET /api/widget/detail and POST /api/widget/update endpoints - Make Google Tasks and Trello cards completable via the widget (Trello completion archives the card); fix Trello description never being fetched, which meant saving could silently wipe a card's real desc - Fix google_tasks.due_date/updated_at (TEXT columns) never round-tripping through sql.NullTime, which broke cached Google Tasks reads whenever the cache was valid - Fix native-task and Google-Task date-range queries excluding anything due before the window start, which dropped incomplete tasks off the widget the moment their due day passed (the "overdue tasks disappeared" bug) - Fix native task description edits blanking the task's title - Make the widget's day list scroll (LazyColumn) instead of clipping - Optimistically remove a task from the widget immediately on completion, ahead of the authoritative background refresh Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL --- .../java/org/terst/doot/widget/data/WidgetItem.kt | 9 ++- .../org/terst/doot/widget/data/WidgetRepository.kt | 47 ++++++++++++++ .../main/java/org/terst/doot/widget/ui/Actions.kt | 6 ++ .../java/org/terst/doot/widget/ui/DootWidget.kt | 57 ++++++++-------- .../org/terst/doot/widget/ui/TaskDetailActivity.kt | 75 +++++++++++++++++++++- 5 files changed, 164 insertions(+), 30 deletions(-) (limited to 'android/app/src/main') 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..4666dd9 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 @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable data class WidgetItem( val id: String, val title: String, - val source: String, // "todoist" | "trello" | "calendar" | "plantoeat" | "gtasks" + val source: String, // "doot" | "trello" | "calendar" | "plantoeat" | "gtasks" val type: String, // "task" | "event" val start: String? = null, // ISO-8601 or null (floating task) val end: String? = null, // ISO-8601 or null @@ -21,3 +21,10 @@ data class WidgetResponse( val now: String, val items: List ) + +@Serializable +data class TaskDetail( + val title: String, + val description: String, + val editable: Boolean +) 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 a54910d..b9f52ce 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 @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.preferences.core.edit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -13,6 +14,21 @@ 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) + +/** + * 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. + */ +suspend fun removeWidgetItemLocally(context: Context, id: String, source: String) { + context.dataStore.edit { prefs -> + val raw = prefs[Keys.ITEMS_JSON] ?: return@edit + val items = runCatching { json.decodeFromString>(raw) }.getOrNull() ?: return@edit + prefs[Keys.ITEMS_JSON] = json.encodeToString(items.filterNot { it.id == id && it.source == source }) + } +} + class WidgetRepository( private val client: OkHttpClient, private val serverUrl: String, @@ -43,6 +59,37 @@ class WidgetRepository( } } + /** Fetches title/description/editability for a single item from /api/widget/detail. */ + suspend fun fetchDetail(id: String, source: String): Result = + withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$serverUrl/api/widget/detail?id=$id&source=$source") + .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" } + json.decodeFromString(body) + } + } + + /** POSTs an edited description to /api/widget/update. */ + suspend fun updateDescription(id: String, source: String, description: String): Result = + withContext(Dispatchers.IO) { + val body = json.encodeToString(UpdateDescriptionRequest(id, source, description)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/update") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + /** POSTs a due-date update to /api/widget/reschedule. */ suspend fun reschedule(id: String, source: String, dateISO: String): Result = withContext(Dispatchers.IO) { 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..f81815f 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 @@ -4,6 +4,8 @@ import android.content.Context 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.removeWidgetItemLocally import org.terst.doot.widget.work.CompleteWorker class CompleteTaskAction : ActionCallback { @@ -14,6 +16,10 @@ class CompleteTaskAction : ActionCallback { ) { val id = parameters[idKey] ?: return val source = parameters[sourceKey] ?: return + // Optimistically drop it from the visible list right away; CompleteWorker below does the + // real completion call and an authoritative refresh once it succeeds. + removeWidgetItemLocally(context, id, source) + DootWidget().updateAll(context) CompleteWorker.enqueue(context, id, source) } 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..1575350 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 @@ -14,6 +14,7 @@ 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.* import androidx.glance.text.FontWeight @@ -78,30 +79,45 @@ fun WidgetRoot(items: List, now: Instant) { .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 } - Column( + // 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. + LazyColumn( modifier = GlanceModifier .fillMaxSize() .background(Color.Transparent) .padding(horizontal = 8.dp, vertical = 4.dp) ) { - Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp)) { - Text( - "TODAY", - style = TextStyle( - color = ColorProvider(Color(0x66FFFFFF)), - fontSize = 11.sp, - fontWeight = FontWeight.Bold + item { + Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp)) { + Text( + "TODAY", + style = TextStyle( + color = ColorProvider(Color(0x66FFFFFF)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) ) - ) + } } - for (hour in gridStart..gridEnd) { - HourRow(hour, nowZoned, scheduledEvents, fragments, zone) + items(count = gridEnd - gridStart + 1) { index -> + HourRow(gridStart + index, nowZoned, scheduledEvents, fragments, zone) } - if (tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() }) { - TomorrowSection(tomorrowItems, tomorrowFrags, 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) + } } } } @@ -284,7 +300,7 @@ fun TaskRow(task: WidgetItem) { } @Composable -fun TomorrowSection(items: List, fragments: List, zone: ZoneId) { +fun TomorrowHeader() { 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)) { @@ -297,19 +313,6 @@ fun TomorrowSection(items: List, fragments: List, zone ) ) } - - 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 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..d14386c 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 @@ -19,8 +19,10 @@ 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.TaskDetail 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.util.Calendar import java.util.TimeZone @@ -37,13 +39,30 @@ class TaskDetailActivity : ComponentActivity() { setContent { MaterialTheme(colorScheme = darkColorScheme()) { + var detail by remember { mutableStateOf(null) } + + LaunchedEffect(id, source) { + if (source in EDITABLE_DETAIL_SOURCES) { + val prefs = this@TaskDetailActivity.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.fetchDetail(id, source).onSuccess { detail = it } + } + } + TaskDetailSheet( title = title, source = source, completable = completable, + detail = detail, onComplete = { - CompleteWorker.enqueue(this, id, source) - finish() + lifecycleScope.launch { + removeWidgetItemLocally(this@TaskDetailActivity, id, source) + DootWidget().updateAll(this@TaskDetailActivity) + CompleteWorker.enqueue(this@TaskDetailActivity, id, source) + finish() + } }, onReschedule = { dateISO -> lifecycleScope.launch { @@ -58,6 +77,19 @@ class TaskDetailActivity : ComponentActivity() { } } }, + onSaveDescription = { description -> + lifecycleScope.launch { + val prefs = this@TaskDetailActivity.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.updateDescription(id, source, description).onSuccess { + repo.fetchAndPersist(this@TaskDetailActivity) + DootWidget().updateAll(this@TaskDetailActivity) + finish() + } + } + }, onDismiss = ::finish ) } @@ -72,17 +104,23 @@ class TaskDetailActivity : ComponentActivity() { } } +/** Sources whose description can be fetched/edited inline in the detail popup. */ +private val EDITABLE_DETAIL_SOURCES = setOf("doot", "gtasks", "trello") + @OptIn(ExperimentalMaterial3Api::class) @Composable fun TaskDetailSheet( title: String, source: String, completable: Boolean, + detail: TaskDetail?, onComplete: () -> Unit, onReschedule: (String) -> Unit, + onSaveDescription: (String) -> Unit, onDismiss: () -> Unit ) { var showDatePicker by remember { mutableStateOf(false) } + var descriptionText by remember(detail) { mutableStateOf(detail?.description ?: "") } val datePickerState = rememberDatePickerState( initialSelectedDateMillis = System.currentTimeMillis() ) @@ -150,6 +188,39 @@ fun TaskDetailSheet( ) } Spacer(Modifier.height(20.dp)) + if (source in EDITABLE_DETAIL_SOURCES) { + if (detail?.editable == true) { + OutlinedTextField( + value = descriptionText, + onValueChange = { descriptionText = it }, + label = { Text("Description") }, + modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = Color.White.copy(alpha = 0.5f), + unfocusedBorderColor = Color.White.copy(alpha = 0.3f), + focusedLabelColor = Color.White.copy(alpha = 0.7f), + unfocusedLabelColor = Color.White.copy(alpha = 0.5f) + ) + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { onSaveDescription(descriptionText) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) + ) { + Text("Save", fontSize = 15.sp) + } + } else { + Text( + "Loading…", + color = Color.White.copy(alpha = 0.5f), + fontSize = 13.sp + ) + } + Spacer(Modifier.height(12.dp)) + } if (completable) { Button( onClick = onComplete, -- cgit v1.2.3