summaryrefslogtreecommitdiff
path: root/android/app/src/main/java/org/terst
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src/main/java/org/terst')
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt9
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt47
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt6
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt57
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt75
5 files changed, 164 insertions, 30 deletions
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<WidgetItem>
)
+
+@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<List<WidgetItem>>(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<TaskDetail> =
+ 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<TaskDetail>(body)
+ }
+ }
+
+ /** POSTs an edited description to /api/widget/update. */
+ suspend fun updateDescription(id: String, source: String, description: String): Result<Unit> =
+ 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<Unit> =
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<WidgetItem>, 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<WidgetItem>, fragments: List<TaskFragment>, 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<WidgetItem>, fragments: List<TaskFragment>, 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<TaskDetail?>(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,