From ffc56608ace1a80f020c53add081514a5428252d Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 15 Jul 2026 00:08:02 +0000 Subject: feat(widget): add WidgetRepository methods for task detail/recurrence --- .../java/org/terst/doot/widget/data/TaskDetail.kt | 22 +++++++ .../org/terst/doot/widget/data/WidgetRepository.kt | 73 ++++++++++++++++++++++ .../org/terst/doot/widget/WidgetRepositoryTest.kt | 43 +++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 android/app/src/main/java/org/terst/doot/widget/data/TaskDetail.kt (limited to 'android') diff --git a/android/app/src/main/java/org/terst/doot/widget/data/TaskDetail.kt b/android/app/src/main/java/org/terst/doot/widget/data/TaskDetail.kt new file mode 100644 index 0000000..3e1d9f8 --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/data/TaskDetail.kt @@ -0,0 +1,22 @@ +package org.terst.doot.widget.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TaskDetailResponse( + val id: String, + val title: String, + val description: String, + @SerialName("due_date") val dueDate: String? = null, + val completed: Boolean = false, + val recurrence: TaskRecurrence? = null, + @SerialName("next_date") val nextDate: String? = null +) + +@Serializable +data class TaskRecurrence( + val freq: String, + val interval: Int, + val weekdays: List = emptyList() +) 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 134e3bc..8b27dac 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 @@ -145,4 +145,77 @@ class WidgetRepository( check(response.isSuccessful) { "HTTP ${response.code}" } } } + + /** GETs full detail for a single doot-native task from /api/widget/task. */ + suspend fun fetchTaskDetail(id: String): Result = + withContext(Dispatchers.IO) { + val encodedId = java.net.URLEncoder.encode(id, "UTF-8") + val request = Request.Builder() + .url("$serverUrl/api/widget/task?id=$encodedId&source=doot") + .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) + } + } + + @Serializable + private data class TaskUpdateRequest(val id: String, val title: String, val description: String) + + /** POSTs a title/description update to /api/widget/task/update. */ + suspend fun updateTask(id: String, title: String, description: String): Result = + withContext(Dispatchers.IO) { + val body = json.encodeToString(TaskUpdateRequest(id, title, description)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/task/update") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + @Serializable + private data class TaskRecurrenceRequest(val id: String, val freq: String, val interval: Int, val weekdays: List) + + /** POSTs a recurrence pattern change to /api/widget/task/recurrence. freq="" clears it. */ + suspend fun setTaskRecurrence(id: String, freq: String, interval: Int, weekdays: List): Result = + withContext(Dispatchers.IO) { + val body = json.encodeToString(TaskRecurrenceRequest(id, freq, interval, weekdays)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/task/recurrence") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + @Serializable + private data class TaskNextDateRequest(val id: String, val date: String) + + /** POSTs a one-shot next-occurrence override to /api/widget/task/next-date. */ + suspend fun setTaskNextDate(id: String, dateISO: String): Result = + withContext(Dispatchers.IO) { + val body = json.encodeToString(TaskNextDateRequest(id, dateISO)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/task/next-date") + .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/test/java/org/terst/doot/widget/WidgetRepositoryTest.kt b/android/app/src/test/java/org/terst/doot/widget/WidgetRepositoryTest.kt index 09fd467..31e6642 100644 --- a/android/app/src/test/java/org/terst/doot/widget/WidgetRepositoryTest.kt +++ b/android/app/src/test/java/org/terst/doot/widget/WidgetRepositoryTest.kt @@ -62,4 +62,47 @@ class WidgetRepositoryTest { assertEquals("Bearer mysecret", capturedRequest.captured.header("Authorization")) } + + @Test + fun `fetchTaskDetail returns success with valid JSON`() = runTest { + val json = """{"id":"rec-1","title":"Water plants","description":"Use the blue can","due_date":"2026-07-13T00:00:00-10:00","completed":false,"recurrence":{"freq":"weekly","interval":1,"weekdays":[1,3,5]},"next_date":"2026-07-15T00:00:00-10:00"}""" + every { mockClient.newCall(any()) } returns mockCall + every { mockCall.execute() } returns responseOf(200, json) + + val repo = WidgetRepository(mockClient, "https://doot.example.com", "token") + val result = repo.fetchTaskDetail("rec-1") + + assertTrue(result.isSuccess) + val detail = result.getOrThrow() + assertEquals("Water plants", detail.title) + assertEquals("weekly", detail.recurrence?.freq) + assertEquals(listOf(1, 3, 5), detail.recurrence?.weekdays) + assertEquals("2026-07-15T00:00:00-10:00", detail.nextDate) + } + + @Test + fun `fetchTaskDetail returns failure on non-200`() = runTest { + every { mockClient.newCall(any()) } returns mockCall + every { mockCall.execute() } returns responseOf(404, "not found") + + val repo = WidgetRepository(mockClient, "https://doot.example.com", "token") + val result = repo.fetchTaskDetail("does-not-exist") + + assertTrue(result.isFailure) + } + + @Test + fun `fetchTaskDetail requests the given id with source=doot`() = runTest { + val json = """{"id":"rec-1","title":"Water plants","description":"","completed":false}""" + val capturedRequest = slot() + every { mockClient.newCall(capture(capturedRequest)) } returns mockCall + every { mockCall.execute() } returns responseOf(200, json) + + val repo = WidgetRepository(mockClient, "https://doot.example.com", "token") + repo.fetchTaskDetail("rec-1") + + val url = capturedRequest.captured.url.toString() + assertTrue("expected id=rec-1 in URL, got $url", url.contains("id=rec-1")) + assertTrue("expected source=doot in URL, got $url", url.contains("source=doot")) + } } -- cgit v1.2.3