summaryrefslogtreecommitdiff
path: root/android/app/src/main/java/org
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src/main/java/org')
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt19
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt216
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt8
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)]