diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-08-09 05:03:09 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-08-09 05:03:09 +0000 |
| commit | b4bb981d98b0c2d4758f5acc0e1deb1f2a651c79 (patch) | |
| tree | 96ba7ba6407bb657e149f97dabeb5bf860eb36fe /android/app/src/main | |
| parent | eadd17df2946a1219fdf02f2ee0a0ac19734e56d (diff) | |
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'android/app/src/main')
| -rw-r--r-- | android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt | 56 | ||||
| -rw-r--r-- | android/app/src/main/java/org/terst/doot/widget/work/AddTaskWorker.kt | 122 |
2 files changed, 142 insertions, 36 deletions
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<String>() - 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<String>() + 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<String>, + 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<AddTaskWorker>() + .setInputData(data) + .build() + WorkManager.getInstance(context).enqueue(request) + } + } +} |
