From b4bb981d98b0c2d4758f5acc0e1deb1f2a651c79 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 9 Aug 2026 05:03:09 +0000 Subject: Widget: Quick Add dismisses instantly instead of blocking on network Tapping "Add" ran addTask() plus up to five sequential follow-up calls (description, due date, project, labels, recurrence) and a widget refresh, all inside the sheet's own lifecycleScope, before finish() -- the sheet sat on screen doing nothing visible for however long that chain took, reported as "sits black for a couple seconds." Moved the actual submission into a new AddTaskWorker, following the same pattern already established by CompleteWorker/DeferWorker: a CoroutineWorker enqueued fire-and-forget, so it survives the activity finishing (lifecycleScope wouldn't -- it's cancelled the moment the activity is destroyed). onAdd now does a local-only DataStore config check (fast, no network, so a missing server URL/token still doesn't silently eat what was typed), then confirms via toast and calls finish() immediately, mirroring the optimistic-dismiss pattern TaskDetailActivity.onComplete already uses. The worker preserves the original partial-failure reporting (a toast listing what didn't stick) for the rare case something after addTask itself fails, now delivered asynchronously via Toast.makeText posted to the main thread rather than blocking the sheet on it. Verification: this exact activity hit the same headless-emulator input limitation noted earlier this session (2026-08-06, QuickAdd keyboard focus) -- confirmed it's an environment constraint, not a regression, by trying both `input text` and raw `input keyevent` injection (which also failed) against a field that visibly has focus, on the same emulator where the same commands work fine for an equivalent OutlinedTextField in SettingsActivity. Verified instead by: go build/ test equivalent (./gradlew testDebugUnitTest, all passing -- the individual WidgetRepository calls AddTaskWorker orchestrates already have unit coverage in WidgetRepositoryTest.kt), a clean assembleDebug, and a crash-sanity launch on emulator-5556 with no FATAL in logcat. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL --- .../org/terst/doot/widget/ui/QuickAddActivity.kt | 56 ++++------ .../org/terst/doot/widget/work/AddTaskWorker.kt | 122 +++++++++++++++++++++ 2 files changed, 142 insertions(+), 36 deletions(-) create mode 100644 android/app/src/main/java/org/terst/doot/widget/work/AddTaskWorker.kt (limited to 'android/app/src/main/java') 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 index 5d658e6..acd3447 100644 --- 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 @@ -17,7 +17,6 @@ import androidx.compose.ui.platform.LocalWindowInfo 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 @@ -27,6 +26,7 @@ import org.terst.doot.widget.data.Project import org.terst.doot.widget.data.TaskRecurrence import org.terst.doot.widget.data.WidgetRepository import org.terst.doot.widget.data.dataStore +import org.terst.doot.widget.work.AddTaskWorker class QuickAddActivity : ComponentActivity() { @@ -79,44 +79,28 @@ class QuickAddActivity : ComponentActivity() { }, onAdd = { title, description -> lifecycleScope.launch { - val r = repo() - if (r == null) { + // A local DataStore read, not a network call -- fast enough to + // check before dismissing, so a missing config doesn't silently + // eat what was typed. + val prefs = this@QuickAddActivity.dataStore.data.first() + val configured = !prefs[Keys.SERVER_URL].isNullOrBlank() && !prefs[Keys.TOKEN].isNullOrBlank() + if (!configured) { toast("Not configured: set server URL/token in Settings") return@launch } - r.addTask(title).onSuccess { id -> - // addTask succeeding only means the task exists -- each - // follow-up call below can independently fail (network - // blip, expired token) without aborting the others, so - // collect what didn't stick rather than assume success - // silently the way the pre-fix code did for addTask itself. - val failed = mutableListOf() - if (description.isNotBlank()) { - r.updateTask(id, title, description).onFailure { failed += "description" } - } - dueDate?.let { if (r.reschedule(id, "doot", it).isFailure) failed += "due date" } - project?.let { if (r.setTaskProject(id, it.id).isFailure) failed += "project" } - if (labels.isNotEmpty()) { - // Same "assign a default color the first time it's colored" - // rule as the edit popup's label editor, so a quick-add task - // with new labels doesn't leave them silently uncolored. - val newlyAdded = labels.filter { it !in knownLabels } - newlyAdded.forEach { label -> r.setLabelColor(label, colorForNewLabel(label)) } - if (r.setTaskLabels(id, labels).isFailure) failed += "labels" - } - recurrence?.let { - if (r.setTaskRecurrence(id, it.freq, it.interval, it.weekdays).isFailure) failed += "recurrence" - } - - r.fetchAndPersist(this@QuickAddActivity) - DootWidget().updateAll(this@QuickAddActivity) - if (failed.isNotEmpty()) { - toast("Task added, but couldn't save: ${failed.joinToString(", ")}") - } - finish() - }.onFailure { e -> - toast("Couldn't add task: ${e.message ?: "network error"}") - } + // Dismiss and confirm immediately instead of blocking on addTask + // plus up to five sequential follow-up calls (description/due + // date/project/labels/recurrence) and a widget refresh -- that + // chain previously left the sheet sitting on screen doing + // nothing visible for a couple of seconds. AddTaskWorker runs + // the actual submission in the background, surviving this + // activity finishing (lifecycleScope wouldn't). + toast("Added \"$title\"") + AddTaskWorker.enqueue( + applicationContext, title, description, dueDate, + project?.id, labels, recurrence + ) + finish() } }, onDismiss = ::finish diff --git a/android/app/src/main/java/org/terst/doot/widget/work/AddTaskWorker.kt b/android/app/src/main/java/org/terst/doot/widget/work/AddTaskWorker.kt new file mode 100644 index 0000000..c038629 --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/work/AddTaskWorker.kt @@ -0,0 +1,122 @@ +package org.terst.doot.widget.work + +import android.content.Context +import android.widget.Toast +import androidx.glance.appwidget.updateAll +import androidx.work.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.TaskRecurrence +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore +import org.terst.doot.widget.ui.DootWidget +import org.terst.doot.widget.ui.colorForNewLabel + +/** + * Runs Quick Add's actual submission -- addTask plus up to five follow-up + * calls (description/due date/project/labels/recurrence), a data refresh, + * and a widget update -- as background work that survives QuickAddActivity + * finishing immediately on tap, instead of the activity's own lifecycleScope + * (which the previous in-place implementation used, and which gets cancelled + * the moment the activity is destroyed). See QuickAddActivity.onAdd: the + * sheet now dismisses and confirms right away rather than sitting on screen + * through this whole sequential chain. + */ +class AddTaskWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val title = inputData.getString(KEY_TITLE) ?: return Result.failure() + val description = inputData.getString(KEY_DESCRIPTION).orEmpty() + val dueDate = inputData.getString(KEY_DUE_DATE) + val projectId = inputData.getString(KEY_PROJECT_ID) + val labels = inputData.getStringArray(KEY_LABELS)?.toList().orEmpty() + val recFreq = inputData.getString(KEY_REC_FREQ) + val recInterval = inputData.getInt(KEY_REC_INTERVAL, 1) + val recWeekdays = inputData.getIntArray(KEY_REC_WEEKDAYS)?.toList().orEmpty() + + 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) + + val id = repo.addTask(title).getOrElse { e -> + toast("Couldn't add task: ${e.message ?: "network error"}") + return Result.failure() + } + + // Each follow-up can independently fail (network blip, expired token) + // without aborting the others -- collect what didn't stick rather + // than assume success silently. + val failed = mutableListOf() + if (description.isNotBlank()) { + repo.updateTask(id, title, description).onFailure { failed += "description" } + } + dueDate?.let { if (repo.reschedule(id, "doot", it).isFailure) failed += "due date" } + projectId?.let { if (repo.setTaskProject(id, it).isFailure) failed += "project" } + if (labels.isNotEmpty()) { + // Same "assign a default color the first time it's colored" rule + // as the edit popup's label editor, so a quick-add task with new + // labels doesn't leave them silently uncolored. Fetches current + // known labels fresh here rather than trusting a snapshot passed + // in from the activity, since this may run well after the sheet + // closed. + val known = repo.fetchLabelColors().getOrNull()?.map { it.name }.orEmpty() + labels.filter { it !in known }.forEach { repo.setLabelColor(it, colorForNewLabel(it)) } + if (repo.setTaskLabels(id, labels).isFailure) failed += "labels" + } + if (!recFreq.isNullOrEmpty()) { + if (repo.setTaskRecurrence(id, recFreq, recInterval, recWeekdays).isFailure) failed += "recurrence" + } + + repo.fetchAndPersist(applicationContext) + DootWidget().updateAll(applicationContext) + + if (failed.isNotEmpty()) { + toast("Task added, but couldn't save: ${failed.joinToString(", ")}") + } + return Result.success() + } + + private suspend fun toast(message: String) = withContext(Dispatchers.Main) { + Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() + } + + companion object { + private const val KEY_TITLE = "title" + private const val KEY_DESCRIPTION = "description" + private const val KEY_DUE_DATE = "due_date" + private const val KEY_PROJECT_ID = "project_id" + private const val KEY_LABELS = "labels" + private const val KEY_REC_FREQ = "rec_freq" + private const val KEY_REC_INTERVAL = "rec_interval" + private const val KEY_REC_WEEKDAYS = "rec_weekdays" + + fun enqueue( + context: Context, + title: String, + description: String, + dueDate: String?, + projectId: String?, + labels: List, + recurrence: TaskRecurrence? + ) { + val data = workDataOf( + KEY_TITLE to title, + KEY_DESCRIPTION to description, + KEY_DUE_DATE to dueDate, + KEY_PROJECT_ID to projectId, + KEY_LABELS to labels.toTypedArray(), + KEY_REC_FREQ to recurrence?.freq, + KEY_REC_INTERVAL to (recurrence?.interval ?: 1), + KEY_REC_WEEKDAYS to (recurrence?.weekdays?.toIntArray() ?: IntArray(0)) + ) + val request = OneTimeWorkRequestBuilder() + .setInputData(data) + .build() + WorkManager.getInstance(context).enqueue(request) + } + } +} -- cgit v1.2.3