diff options
5 files changed, 175 insertions, 2 deletions
diff --git a/android/app/src/main/java/org/terst/doot/widget/data/Project.kt b/android/app/src/main/java/org/terst/doot/widget/data/Project.kt new file mode 100644 index 0000000..43ac63b --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/data/Project.kt @@ -0,0 +1,17 @@ +package org.terst.doot.widget.data + +import kotlinx.serialization.Serializable + +@Serializable +data class Project( + val id: String, + val name: String, + val color: String, + @kotlinx.serialization.SerialName("created_at") val createdAt: String +) + +@Serializable +data class LabelColor( + val name: String, + val color: String +) 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 index 3e1d9f8..515678e 100644 --- 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 @@ -11,7 +11,9 @@ data class TaskDetailResponse( @SerialName("due_date") val dueDate: String? = null, val completed: Boolean = false, val recurrence: TaskRecurrence? = null, - @SerialName("next_date") val nextDate: String? = null + @SerialName("next_date") val nextDate: String? = null, + val project: Project? = null, + val labels: List<String> = emptyList() ) @Serializable 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 fee15e1..92598ba 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 @@ -16,7 +16,8 @@ data class WidgetItem( @SerialName("due_date") val dueDate: String? = null, val url: String = "", val completable: Boolean = false, - @SerialName("recurring_event_id") val recurringEventId: String? = null + @SerialName("recurring_event_id") val recurringEventId: String? = null, + @SerialName("project_color") val projectColor: 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 8b27dac..16c76f2 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.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -218,4 +219,112 @@ class WidgetRepository( check(response.isSuccessful) { "HTTP ${response.code}" } } } + + /** GETs the non-archived project list from /api/widget/projects. */ + suspend fun fetchProjects(): Result<List<Project>> = + withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$serverUrl/api/widget/projects") + .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<List<Project>>(body) + } + } + + @Serializable + private data class ProjectCreateRequest(val name: String, val color: String) + + /** POSTs a new project to /api/widget/projects. */ + suspend fun createProject(name: String, color: String): Result<Project> = + withContext(Dispatchers.IO) { + val body = json.encodeToString(ProjectCreateRequest(name, color)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/projects") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + val responseBody = checkNotNull(response.body?.string()) { "Empty body" } + json.decodeFromString<Project>(responseBody) + } + } + + @Serializable + private data class TaskProjectRequest(val id: String, @SerialName("project_id") val projectId: String) + + /** POSTs a project assignment to /api/widget/task/project. projectId="" clears it. */ + suspend fun setTaskProject(id: String, projectId: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = json.encodeToString(TaskProjectRequest(id, projectId)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/task/project") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + @Serializable + private data class TaskLabelsRequest(val id: String, val labels: List<String>) + + /** POSTs a full label-set replacement to /api/widget/task/labels. */ + suspend fun setTaskLabels(id: String, labels: List<String>): Result<Unit> = + withContext(Dispatchers.IO) { + val body = json.encodeToString(TaskLabelsRequest(id, labels)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/task/labels") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + /** GETs every colored label from /api/widget/labels. */ + suspend fun fetchLabelColors(): Result<List<LabelColor>> = + withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$serverUrl/api/widget/labels") + .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<List<LabelColor>>(body) + } + } + + @Serializable + private data class LabelColorSetRequest(val name: String, val color: String) + + /** POSTs a label color assignment to /api/widget/labels/color. */ + suspend fun setLabelColor(name: String, color: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = json.encodeToString(LabelColorSetRequest(name, color)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/labels/color") + .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 31e6642..8712124 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 @@ -105,4 +105,48 @@ class WidgetRepositoryTest { 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")) } + + @Test + fun `fetchProjects returns success with valid JSON`() = runTest { + val json = """[{"id":"proj-1","name":"Sailing prep","color":"#3B82F6","created_at":"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.fetchProjects() + + assertTrue(result.isSuccess) + val projects = result.getOrThrow() + assertEquals(1, projects.size) + assertEquals("Sailing prep", projects[0].name) + assertEquals("#3B82F6", projects[0].color) + } + + @Test + fun `createProject returns success with valid JSON`() = runTest { + val json = """{"id":"proj-1","name":"Sailing prep","color":"#3B82F6","created_at":"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.createProject("Sailing prep", "#3B82F6") + + assertTrue(result.isSuccess) + assertEquals("Sailing prep", result.getOrThrow().name) + } + + @Test + fun `fetchLabelColors returns success with valid JSON`() = runTest { + val json = """[{"name":"chore","color":"#F59E0B"}]""" + 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.fetchLabelColors() + + assertTrue(result.isSuccess) + val colors = result.getOrThrow() + assertEquals(1, colors.size) + assertEquals("chore", colors[0].name) + } } |
