From 8273af4d0c5893e4e0647596b94f96886271cc03 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 15 Jul 2026 00:18:13 +0000 Subject: feat(widget): redesign task detail popup with editing and recurrence Editable title/description (Edit -> Cancel/Save toggle), linkified description, and independently-tappable date/recurrence/next-date chips. Non-doot sources (Trello, Google Tasks) are unaffected -- same title + Complete button as before, no live fetch, no edit UI. --- .../terst/doot/widget/ui/RecurrenceEditDialog.kt | 91 ++++++ .../org/terst/doot/widget/ui/TaskDetailActivity.kt | 342 +++++++++++++++------ 2 files changed, 334 insertions(+), 99 deletions(-) create mode 100644 android/app/src/main/java/org/terst/doot/widget/ui/RecurrenceEditDialog.kt (limited to 'android/app/src/main/java') diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/RecurrenceEditDialog.kt b/android/app/src/main/java/org/terst/doot/widget/ui/RecurrenceEditDialog.kt new file mode 100644 index 0000000..62fb549 --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/ui/RecurrenceEditDialog.kt @@ -0,0 +1,91 @@ +package org.terst.doot.widget.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import org.terst.doot.widget.data.TaskRecurrence + +internal fun weekdayAbbrev(day: Int): String = when (day) { + 0 -> "Sun"; 1 -> "Mon"; 2 -> "Tue"; 3 -> "Wed"; 4 -> "Thu"; 5 -> "Fri"; 6 -> "Sat" + else -> "?" +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecurrenceEditDialog( + initial: TaskRecurrence?, + onDismiss: () -> Unit, + onSave: (freq: String, interval: Int, weekdays: List) -> Unit, + onClear: () -> Unit +) { + var freq by remember { mutableStateOf(initial?.freq ?: "weekly") } + var interval by remember { mutableStateOf((initial?.interval ?: 1).toString()) } + var weekdays by remember { mutableStateOf(initial?.weekdays?.toSet() ?: emptySet()) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Recurrence") }, + text = { + Column { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf("daily", "weekly", "monthly", "yearly").forEach { option -> + FilterChip( + selected = freq == option, + onClick = { freq = option }, + label = { Text(option.replaceFirstChar { it.uppercase() }) } + ) + } + } + OutlinedTextField( + value = interval, + onValueChange = { if (it.all(Char::isDigit)) interval = it }, + label = { Text("Every N ${freq}${if (interval != "1") "s" else ""}") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true + ) + if (freq == "weekly") { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + (0..6).forEach { day -> + FilterChip( + selected = weekdays.contains(day), + onClick = { + weekdays = if (weekdays.contains(day)) weekdays - day else weekdays + day + }, + label = { Text(weekdayAbbrev(day)) } + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { + onSave(freq, interval.toIntOrNull()?.coerceAtLeast(1) ?: 1, weekdays.sorted()) + }) { Text("Save") } + }, + dismissButton = { + Row { + if (initial != null) { + TextButton(onClick = onClear) { Text("Clear") } + } + TextButton(onClick = onDismiss) { Text("Cancel") } + } + } + ) +} 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 3aeafc5..52a5d30 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 @@ -1,16 +1,20 @@ package org.terst.doot.widget.ui +import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.glance.appwidget.updateAll @@ -19,7 +23,8 @@ 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.TaskDetailResponse +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.data.removeWidgetItemLocally @@ -36,21 +41,41 @@ class TaskDetailActivity : ComponentActivity() { val id = intent.getStringExtra(EXTRA_ID) ?: return finish() val source = intent.getStringExtra(EXTRA_SOURCE) ?: return finish() - val title = intent.getStringExtra(EXTRA_TITLE) ?: "" + val initialTitle = intent.getStringExtra(EXTRA_TITLE) ?: "" val completable = intent.getBooleanExtra(EXTRA_COMPLETABLE, false) - val dueDate = intent.getStringExtra(EXTRA_DUE_DATE) + val initialDueDate = intent.getStringExtra(EXTRA_DUE_DATE) + val isDoot = source == "doot" setContent { MaterialTheme(colorScheme = darkColorScheme()) { - var detail by remember { mutableStateOf(null) } + var title by remember { mutableStateOf(initialTitle) } + var description by remember { mutableStateOf("") } + var dueDate by remember { mutableStateOf(initialDueDate) } + var recurrence by remember { mutableStateOf(null) } + var nextDate by remember { mutableStateOf(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 } + suspend fun repo(): WidgetRepository? { + val prefs = this@TaskDetailActivity.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return null + val token = prefs[Keys.TOKEN] ?: return null + return WidgetRepository(OkHttpClient(), url, token) + } + + suspend fun refreshWidget() { + val r = repo() ?: return + r.fetchAndPersist(this@TaskDetailActivity) + DootWidget().updateAll(this@TaskDetailActivity) + } + + if (isDoot) { + LaunchedEffect(id) { + repo()?.fetchTaskDetail(id)?.onSuccess { detail: TaskDetailResponse -> + title = detail.title + description = detail.description + dueDate = detail.dueDate + recurrence = detail.recurrence + nextDate = detail.nextDate + } } } @@ -58,8 +83,11 @@ class TaskDetailActivity : ComponentActivity() { title = title, source = source, completable = completable, - detail = detail, dueDate = dueDate, + isDoot = isDoot, + description = description, + recurrence = recurrence, + nextDate = nextDate, onComplete = { lifecycleScope.launch { removeWidgetItemLocally(this@TaskDetailActivity, id, source) @@ -70,30 +98,46 @@ class TaskDetailActivity : ComponentActivity() { }, onReschedule = { dateISO -> 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.reschedule(id, source, dateISO).onSuccess { - repo.fetchAndPersist(this@TaskDetailActivity) - DootWidget().updateAll(this@TaskDetailActivity) + repo()?.reschedule(id, source, dateISO)?.onSuccess { + refreshWidget() finish() } } }, - onSaveDescription = { description -> + onSaveEdit = { newTitle, newDescription -> 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() + repo()?.updateTask(id, newTitle, newDescription)?.onSuccess { + title = newTitle + description = newDescription + refreshWidget() + } + } + }, + onSaveRecurrence = { freq, interval, weekdays -> + lifecycleScope.launch { + repo()?.setTaskRecurrence(id, freq, interval, weekdays)?.onSuccess { + repo()?.fetchTaskDetail(id)?.onSuccess { detail -> + recurrence = detail.recurrence + nextDate = detail.nextDate + } + refreshWidget() + } + } + }, + onSaveNextDate = { dateISO -> + lifecycleScope.launch { + repo()?.setTaskNextDate(id, dateISO)?.onSuccess { + nextDate = dateISO + refreshWidget() } } }, + onOpenUrl = { url -> + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }, + onDialPhone = { phone -> + startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone"))) + }, onDismiss = ::finish ) } @@ -109,43 +153,42 @@ 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?, dueDate: String?, + isDoot: Boolean, + description: String, + recurrence: TaskRecurrence?, + nextDate: String?, onComplete: () -> Unit, onReschedule: (String) -> Unit, - onSaveDescription: (String) -> Unit, + onSaveEdit: (title: String, description: String) -> Unit, + onSaveRecurrence: (freq: String, interval: Int, weekdays: List) -> Unit, + onSaveNextDate: (String) -> Unit, + onOpenUrl: (String) -> Unit, + onDialPhone: (String) -> Unit, onDismiss: () -> Unit ) { + var isEditing by remember { mutableStateOf(false) } + var editTitle by remember(title) { mutableStateOf(title) } + var editDescription by remember(description) { mutableStateOf(description) } var showDatePicker by remember { mutableStateOf(false) } - var descriptionText by remember(detail) { mutableStateOf(detail?.description ?: "") } - val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = System.currentTimeMillis() - ) + var showRecurrenceDialog by remember { mutableStateOf(false) } + var showNextDatePicker by remember { mutableStateOf(false) } + + val datePickerState = rememberDatePickerState(initialSelectedDateMillis = System.currentTimeMillis()) + val nextDatePickerState = rememberDatePickerState(initialSelectedDateMillis = System.currentTimeMillis()) if (showDatePicker) { DatePickerDialog( onDismissRequest = { showDatePicker = false }, confirmButton = { TextButton(onClick = { - datePickerState.selectedDateMillis?.let { millis -> - val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) - cal.timeInMillis = millis - val dateISO = "%04d-%02d-%02d".format( - cal.get(Calendar.YEAR), - cal.get(Calendar.MONTH) + 1, - cal.get(Calendar.DAY_OF_MONTH) - ) - onReschedule(dateISO) - } + datePickerState.selectedDateMillis?.let { millis -> onReschedule(isoDateFromMillis(millis)) } showDatePicker = false }) { Text("Set date") } }, @@ -157,6 +200,38 @@ fun TaskDetailSheet( } } + if (showNextDatePicker) { + DatePickerDialog( + onDismissRequest = { showNextDatePicker = false }, + confirmButton = { + TextButton(onClick = { + nextDatePickerState.selectedDateMillis?.let { millis -> onSaveNextDate(isoDateFromMillis(millis)) } + showNextDatePicker = false + }) { Text("Set next date") } + }, + dismissButton = { + TextButton(onClick = { showNextDatePicker = false }) { Text("Cancel") } + } + ) { + DatePicker(state = nextDatePickerState) + } + } + + if (showRecurrenceDialog) { + RecurrenceEditDialog( + initial = recurrence, + onDismiss = { showRecurrenceDialog = false }, + onSave = { freq, interval, weekdays -> + onSaveRecurrence(freq, interval, weekdays) + showRecurrenceDialog = false + }, + onClear = { + onSaveRecurrence("", 1, emptyList()) + showRecurrenceDialog = false + } + ) + } + ModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), @@ -185,66 +260,105 @@ fun TaskDetailSheet( .background(sourceColor(source), RoundedCornerShape(5.dp)) ) Spacer(Modifier.width(10.dp)) - Text( - text = title, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White, - modifier = Modifier.weight(1f) - ) - } - Spacer(Modifier.height(20.dp)) - if (source in EDITABLE_DETAIL_SOURCES) { - if (detail?.editable == true) { + if (isEditing) { 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) - ) + value = editTitle, + onValueChange = { editTitle = it }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) - 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 + text = title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + modifier = Modifier.weight(1f) ) } - Spacer(Modifier.height(12.dp)) } - if (completable) { - Button( - onClick = onComplete, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) - ) { - Text("Mark Complete", fontSize = 15.sp) + + if (isDoot) { + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + AssistChip( + onClick = { showDatePicker = true }, + label = { Text(formatDueDateLabel(dueDate), fontSize = 13.sp) } + ) + Spacer(Modifier.width(8.dp)) + AssistChip( + onClick = { showRecurrenceDialog = true }, + label = { Text(formatRecurrenceLabel(recurrence), fontSize = 13.sp) } + ) + if (recurrence != null) { + Spacer(Modifier.width(8.dp)) + AssistChip( + onClick = { showNextDatePicker = true }, + label = { Text(formatNextDateLabel(nextDate), fontSize = 13.sp) } + ) + } + } + + Spacer(Modifier.height(16.dp)) + if (isEditing) { + OutlinedTextField( + value = editDescription, + onValueChange = { editDescription = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 100.dp), + placeholder = { Text("Description") } + ) + } else if (description.isNotEmpty()) { + LinkifiedText( + text = description, + modifier = Modifier.fillMaxWidth(), + onOpenUrl = onOpenUrl, + onDialPhone = onDialPhone + ) } - Spacer(Modifier.height(8.dp)) } - if (source == "doot") { - OutlinedButton( - onClick = { showDatePicker = true }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), - border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) - ) { - Text(formatDueDateLabel(dueDate), fontSize = 15.sp) + + Spacer(Modifier.height(20.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (isDoot && isEditing) { + OutlinedButton( + onClick = { + editTitle = title + editDescription = description + isEditing = false + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + ) { Text("Cancel") } + Button( + onClick = { + onSaveEdit(editTitle, editDescription) + isEditing = false + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)), + enabled = editTitle.isNotBlank() + ) { Text("Save") } + } else { + if (isDoot) { + OutlinedButton( + onClick = { isEditing = true }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + ) { Text("Edit") } + } + if (completable) { + Button( + onClick = onComplete, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) + ) { Text("Complete") } + } } } Spacer(Modifier.height(20.dp)) @@ -256,6 +370,36 @@ private fun formatDueDateLabel(dueDate: String?): String { if (dueDate == null) return "No due date · tap to schedule" return runCatching { val date = LocalDate.parse(dueDate.substring(0, 10)) - "Due " + date.format(DateTimeFormatter.ofPattern("MMM d")) + date.format(DateTimeFormatter.ofPattern("MMM d")) }.getOrDefault("No due date · tap to schedule") } + +private fun formatRecurrenceLabel(recurrence: TaskRecurrence?): String { + if (recurrence == null) return "Set recurrence" + val intervalPrefix = if (recurrence.interval > 1) "every ${recurrence.interval} " else "" + val unit = when (recurrence.freq) { + "daily" -> if (recurrence.interval > 1) "days" else "daily" + "weekly" -> if (recurrence.interval > 1) "weeks" else "weekly" + "monthly" -> if (recurrence.interval > 1) "months" else "monthly" + "yearly" -> if (recurrence.interval > 1) "years" else "yearly" + else -> recurrence.freq + } + val weekdaysSuffix = if (recurrence.freq == "weekly" && recurrence.weekdays.isNotEmpty()) { + " on " + recurrence.weekdays.sorted().joinToString(", ") { weekdayAbbrev(it) } + } else "" + return "🔄 $intervalPrefix$unit$weekdaysSuffix" +} + +private fun formatNextDateLabel(nextDate: String?): String { + if (nextDate == null) return "Next: —" + return runCatching { + val date = LocalDate.parse(nextDate.substring(0, 10)) + "Next: " + date.format(DateTimeFormatter.ofPattern("MMM d")) + }.getOrDefault("Next: —") +} + +private fun isoDateFromMillis(millis: Long): String { + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.timeInMillis = millis + return "%04d-%02d-%02d".format(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)) +} -- cgit v1.2.3