diff options
Diffstat (limited to 'android/app/src/main/java')
3 files changed, 217 insertions, 26 deletions
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 4aab37c..3283ea8 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 @@ -20,6 +20,9 @@ private data class UpdateDescriptionRequest(val id: String, val source: String, @Serializable private data class WidgetAddRequest(val title: String) +@Serializable +private data class WidgetAddResponse(val id: 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. @@ -131,13 +134,15 @@ class WidgetRepository( } /** - * POSTs a new task title to /api/widget/add. Uses proper JSON encoding - * (not manual string interpolation like reschedule/complete above) because - * the title is arbitrary user text that could contain characters that - * break hand-built JSON -- id/source above are safe because they're - * internal identifiers, never user-typed free text. + * POSTs a new task title to /api/widget/add and returns its id, so the + * caller can follow up with the same project/labels/recurrence/due-date + * setter calls the edit popup uses. Uses proper JSON encoding (not manual + * string interpolation like reschedule/complete above) because the title + * is arbitrary user text that could contain characters that break + * hand-built JSON -- id/source above are safe because they're internal + * identifiers, never user-typed free text. */ - suspend fun addTask(title: String): Result<Unit> = + suspend fun addTask(title: String): Result<String> = withContext(Dispatchers.IO) { val body = json.encodeToString(WidgetAddRequest(title)) .toRequestBody("application/json".toMediaType()) @@ -149,6 +154,8 @@ class WidgetRepository( runCatching { val response = client.newCall(request).execute() check(response.isSuccessful) { "HTTP ${response.code}" } + val responseBody = checkNotNull(response.body?.string()) { "Empty body" } + json.decodeFromString<WidgetAddResponse>(responseBody).id } } 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 bb6bad0..18373c0 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 @@ -9,16 +9,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController 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.delay 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.Project +import org.terst.doot.widget.data.TaskRecurrence import org.terst.doot.widget.data.WidgetRepository import org.terst.doot.widget.data.dataStore @@ -29,15 +35,64 @@ class QuickAddActivity : ComponentActivity() { setContent { MaterialTheme(colorScheme = darkColorScheme()) { + var dueDate by remember { mutableStateOf<String?>(null) } + var recurrence by remember { mutableStateOf<TaskRecurrence?>(null) } + var project by remember { mutableStateOf<Project?>(null) } + var labels by remember { mutableStateOf<List<String>>(emptyList()) } + var availableProjects by remember { mutableStateOf<List<Project>>(emptyList()) } + var knownLabels by remember { mutableStateOf<List<String>>(emptyList()) } + + suspend fun repo(): WidgetRepository? { + val prefs = this@QuickAddActivity.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return null + val token = prefs[Keys.TOKEN] ?: return null + return WidgetRepository(OkHttpClient(), url, token) + } + + LaunchedEffect(Unit) { + repo()?.fetchProjects()?.onSuccess { availableProjects = it } + repo()?.fetchLabelColors()?.onSuccess { colors -> knownLabels = colors.map { it.name } } + } + QuickAddSheet( - onAdd = { title -> + dueDate = dueDate, + recurrence = recurrence, + project = project, + labels = labels, + availableProjects = availableProjects, + knownLabels = knownLabels, + onSetDueDate = { dueDate = it }, + onSetRecurrence = { recurrence = it }, + onSetProject = { project = it }, + onSetLabels = { labels = it }, + onCreateProject = { name, color -> lifecycleScope.launch { - val prefs = this@QuickAddActivity.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.addTask(title).onSuccess { - repo.fetchAndPersist(this@QuickAddActivity) + repo()?.createProject(name, color)?.onSuccess { newProject -> + project = newProject + availableProjects = availableProjects + newProject + } + } + }, + onAdd = { title, description -> + lifecycleScope.launch { + val r = repo() ?: return@launch + r.addTask(title).onSuccess { id -> + if (description.isNotBlank()) { + r.updateTask(id, title, description) + } + dueDate?.let { r.reschedule(id, "doot", it) } + project?.let { r.setTaskProject(id, it.id) } + 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)) } + r.setTaskLabels(id, labels) + } + recurrence?.let { r.setTaskRecurrence(id, it.freq, it.interval, it.weekdays) } + + r.fetchAndPersist(this@QuickAddActivity) DootWidget().updateAll(this@QuickAddActivity) finish() } @@ -53,10 +108,91 @@ class QuickAddActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable fun QuickAddSheet( - onAdd: (String) -> Unit, + dueDate: String?, + recurrence: TaskRecurrence?, + project: Project?, + labels: List<String>, + availableProjects: List<Project>, + knownLabels: List<String>, + onSetDueDate: (String?) -> Unit, + onSetRecurrence: (TaskRecurrence?) -> Unit, + onSetProject: (Project?) -> Unit, + onSetLabels: (List<String>) -> Unit, + onCreateProject: (name: String, color: String) -> Unit, + onAdd: (title: String, description: String) -> Unit, onDismiss: () -> Unit ) { var title by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var showDatePicker by remember { mutableStateOf(false) } + var showRecurrenceDialog by remember { mutableStateOf(false) } + var showProjectPicker by remember { mutableStateOf(false) } + var showLabelEditor by remember { mutableStateOf(false) } + + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + delay(150) + focusRequester.requestFocus() + keyboard?.show() + } + + val datePickerState = rememberDatePickerState(initialSelectedDateMillis = System.currentTimeMillis()) + + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { millis -> onSetDueDate(isoDateFromMillis(millis)) } + showDatePicker = false + }) { Text("Set date") } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { Text("Cancel") } + } + ) { + DatePicker(state = datePickerState) + } + } + + if (showRecurrenceDialog) { + RecurrenceEditDialog( + initial = recurrence, + onDismiss = { showRecurrenceDialog = false }, + onSave = { freq, interval, weekdays -> + onSetRecurrence(TaskRecurrence(freq, interval, weekdays)) + showRecurrenceDialog = false + }, + onClear = { + onSetRecurrence(null) + showRecurrenceDialog = false + } + ) + } + + if (showProjectPicker) { + ProjectPickerDialog( + projects = availableProjects, + onDismiss = { showProjectPicker = false }, + onSelect = { projectId -> + onSetProject(availableProjects.find { it.id == projectId }) + showProjectPicker = false + }, + onClear = { onSetProject(null); showProjectPicker = false }, + onCreate = { name, color -> onCreateProject(name, color); showProjectPicker = false } + ) + } + + if (showLabelEditor) { + LabelEditorDialog( + initial = labels, + knownLabels = knownLabels, + onDismiss = { showLabelEditor = false }, + onSave = { newLabels -> onSetLabels(newLabels); showLabelEditor = false } + ) + } ModalBottomSheet( onDismissRequest = onDismiss, @@ -91,7 +227,9 @@ fun QuickAddSheet( value = title, onValueChange = { title = it }, placeholder = { Text("Task title") }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), singleLine = true, colors = OutlinedTextFieldDefaults.colors( focusedTextColor = Color.White, @@ -100,14 +238,60 @@ fun QuickAddSheet( unfocusedBorderColor = Color.White.copy(alpha = 0.3f) ) ) + + 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) } + ) + Spacer(Modifier.width(8.dp)) + AssistChip( + onClick = { showProjectPicker = true }, + label = { Text(project?.name ?: "Set project", fontSize = 13.sp) } + ) + Spacer(Modifier.width(8.dp)) + AssistChip( + onClick = { showLabelEditor = true }, + label = { Text(if (labels.isEmpty()) "Add labels" else labels.joinToString(", "), fontSize = 13.sp) } + ) + } + Spacer(Modifier.height(16.dp)) - Button( - onClick = { onAdd(title.trim()) }, - enabled = title.isNotBlank(), - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) - ) { - Text("Add", fontSize = 15.sp) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + placeholder = { Text("Description") }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 100.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = Color.White.copy(alpha = 0.5f), + unfocusedBorderColor = Color.White.copy(alpha = 0.3f) + ) + ) + + Spacer(Modifier.height(20.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onDismiss, + 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 = { onAdd(title.trim(), description.trim()) }, + enabled = title.isNotBlank(), + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) + ) { Text("Add") } } Spacer(Modifier.height(20.dp)) } 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 8cb37fd..f486dec 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 @@ -455,7 +455,7 @@ fun TaskDetailSheet( } } -private fun formatDueDateLabel(dueDate: String?): String { +internal 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)) @@ -463,7 +463,7 @@ private fun formatDueDateLabel(dueDate: String?): String { }.getOrDefault("No due date · tap to schedule") } -private fun formatRecurrenceLabel(recurrence: TaskRecurrence?): String { +internal 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) { @@ -487,7 +487,7 @@ private fun formatNextDateLabel(nextDate: String?): String { }.getOrDefault("Next: —") } -private fun isoDateFromMillis(millis: Long): String { +internal 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)) @@ -499,5 +499,5 @@ private val LABEL_COLOR_PALETTE = listOf( /** Deterministic so the same label name always lands on the same color, even * across sessions, without needing to remember a prior assignment. */ -private fun colorForNewLabel(name: String): String = +internal fun colorForNewLabel(name: String): String = LABEL_COLOR_PALETTE[Math.floorMod(name.hashCode(), LABEL_COLOR_PALETTE.size)] |
