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/ui/LabelEditorDialog.kt80
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/ProjectPickerDialog.kt110
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt98
3 files changed, 288 insertions, 0 deletions
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/LabelEditorDialog.kt b/android/app/src/main/java/org/terst/doot/widget/ui/LabelEditorDialog.kt
new file mode 100644
index 0000000..53aff79
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/LabelEditorDialog.kt
@@ -0,0 +1,80 @@
+package org.terst.doot.widget.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+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.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun LabelEditorDialog(
+ initial: List<String>,
+ knownLabels: List<String>,
+ onDismiss: () -> Unit,
+ onSave: (labels: List<String>) -> Unit
+) {
+ var labels by remember { mutableStateOf(initial.toMutableList()) }
+ var newLabel by remember { mutableStateOf("") }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Labels") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ if (labels.isNotEmpty()) {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ labels.forEach { label ->
+ FilterChip(
+ selected = true,
+ onClick = { labels = (labels - label).toMutableList() },
+ label = { Text(label) }
+ )
+ }
+ }
+ }
+ OutlinedTextField(
+ value = newLabel,
+ onValueChange = { newLabel = it },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ label = { Text("Add a label") }
+ )
+ if (knownLabels.isNotEmpty()) {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ knownLabels.filter { it !in labels }.forEach { label ->
+ FilterChip(
+ selected = false,
+ onClick = { labels = (labels + label).toMutableList() },
+ label = { Text(label) }
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ val finalLabels = if (newLabel.isNotBlank()) labels + newLabel else labels
+ onSave(finalLabels)
+ }) { Text("Save") }
+ },
+ dismissButton = {
+ Row { TextButton(onClick = onDismiss) { Text("Cancel") } }
+ }
+ )
+}
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/ProjectPickerDialog.kt b/android/app/src/main/java/org/terst/doot/widget/ui/ProjectPickerDialog.kt
new file mode 100644
index 0000000..a7489da
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/ProjectPickerDialog.kt
@@ -0,0 +1,110 @@
+package org.terst.doot.widget.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import org.terst.doot.widget.data.Project
+
+private val PROJECT_COLOR_CHOICES = listOf(
+ "#3B82F6", "#EF4444", "#F59E0B", "#10B981", "#8B5CF6", "#EC4899", "#6B7280"
+)
+
+/** Parses a "#RRGGBB" string into a Compose Color, defaulting to gray on failure. */
+internal fun parseHexColor(hex: String): Color = runCatching {
+ Color(android.graphics.Color.parseColor(hex))
+}.getOrDefault(Color.Gray)
+
+@Composable
+fun ProjectPickerDialog(
+ projects: List<Project>,
+ onDismiss: () -> Unit,
+ onSelect: (projectId: String) -> Unit,
+ onClear: () -> Unit,
+ onCreate: (name: String, color: String) -> Unit
+) {
+ var newName by remember { mutableStateOf("") }
+ var newColor by remember { mutableStateOf(PROJECT_COLOR_CHOICES.first()) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Project") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ projects.forEach { project ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onSelect(project.id) }
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(14.dp)
+ .background(parseHexColor(project.color), CircleShape)
+ )
+ Text(project.name, modifier = Modifier.padding(start = 10.dp))
+ }
+ }
+ Text("New project", modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
+ OutlinedTextField(
+ value = newName,
+ onValueChange = { newName = it },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ modifier = Modifier.fillMaxWidth()
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 8.dp)) {
+ PROJECT_COLOR_CHOICES.forEach { hex ->
+ val isSelected = hex == newColor
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .background(parseHexColor(hex), CircleShape)
+ .then(
+ if (isSelected) Modifier.border(2.dp, Color.White, CircleShape)
+ else Modifier
+ )
+ .clickable { newColor = hex }
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onCreate(newName, newColor) },
+ enabled = newName.isNotBlank()
+ ) { Text("Create") }
+ },
+ dismissButton = {
+ Row {
+ 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 52a5d30..8cb37fd 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
@@ -23,6 +23,7 @@ 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.TaskDetailResponse
import org.terst.doot.widget.data.TaskRecurrence
import org.terst.doot.widget.data.WidgetRepository
@@ -53,6 +54,10 @@ class TaskDetailActivity : ComponentActivity() {
var dueDate by remember { mutableStateOf(initialDueDate) }
var recurrence by remember { mutableStateOf<TaskRecurrence?>(null) }
var nextDate by remember { mutableStateOf<String?>(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@TaskDetailActivity.dataStore.data.first()
@@ -75,7 +80,11 @@ class TaskDetailActivity : ComponentActivity() {
dueDate = detail.dueDate
recurrence = detail.recurrence
nextDate = detail.nextDate
+ project = detail.project
+ labels = detail.labels
}
+ repo()?.fetchProjects()?.onSuccess { availableProjects = it }
+ repo()?.fetchLabelColors()?.onSuccess { colors -> knownLabels = colors.map { it.name } }
}
}
@@ -88,6 +97,10 @@ class TaskDetailActivity : ComponentActivity() {
description = description,
recurrence = recurrence,
nextDate = nextDate,
+ project = project,
+ labels = labels,
+ availableProjects = availableProjects,
+ knownLabels = knownLabels,
onComplete = {
lifecycleScope.launch {
removeWidgetItemLocally(this@TaskDetailActivity, id, source)
@@ -132,6 +145,44 @@ class TaskDetailActivity : ComponentActivity() {
}
}
},
+ onSaveProject = { projectId ->
+ lifecycleScope.launch {
+ repo()?.setTaskProject(id, projectId)?.onSuccess {
+ project = availableProjects.find { it.id == projectId }
+ refreshWidget()
+ }
+ }
+ },
+ onCreateProject = { name, color ->
+ lifecycleScope.launch {
+ repo()?.createProject(name, color)?.onSuccess { newProject ->
+ repo()?.setTaskProject(id, newProject.id)?.onSuccess {
+ project = newProject
+ availableProjects = availableProjects + newProject
+ refreshWidget()
+ }
+ }
+ }
+ },
+ onSaveLabels = { newLabels ->
+ lifecycleScope.launch {
+ // Assign every genuinely new label (not already in knownLabels) a
+ // deterministic color from a fixed palette, per the spec's
+ // "gets a default color... the first time it's colored" -- without
+ // this, SetLabelColor/GetLabelColors (already built server-side)
+ // would never actually get called from the UI, and every label
+ // would silently stay uncolored forever.
+ val newlyAdded = newLabels.filter { it !in knownLabels }
+ newlyAdded.forEach { label -> repo()?.setLabelColor(label, colorForNewLabel(label)) }
+ if (newlyAdded.isNotEmpty()) {
+ knownLabels = knownLabels + newlyAdded
+ }
+ repo()?.setTaskLabels(id, newLabels)?.onSuccess {
+ labels = newLabels
+ refreshWidget()
+ }
+ }
+ },
onOpenUrl = { url ->
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
},
@@ -164,11 +215,18 @@ fun TaskDetailSheet(
description: String,
recurrence: TaskRecurrence?,
nextDate: String?,
+ project: Project?,
+ labels: List<String>,
+ availableProjects: List<Project>,
+ knownLabels: List<String>,
onComplete: () -> Unit,
onReschedule: (String) -> Unit,
onSaveEdit: (title: String, description: String) -> Unit,
onSaveRecurrence: (freq: String, interval: Int, weekdays: List<Int>) -> Unit,
onSaveNextDate: (String) -> Unit,
+ onSaveProject: (projectId: String) -> Unit,
+ onCreateProject: (name: String, color: String) -> Unit,
+ onSaveLabels: (labels: List<String>) -> Unit,
onOpenUrl: (String) -> Unit,
onDialPhone: (String) -> Unit,
onDismiss: () -> Unit
@@ -179,6 +237,8 @@ fun TaskDetailSheet(
var showDatePicker by remember { mutableStateOf(false) }
var showRecurrenceDialog by remember { mutableStateOf(false) }
var showNextDatePicker by remember { mutableStateOf(false) }
+ var showProjectPicker by remember { mutableStateOf(false) }
+ var showLabelEditor by remember { mutableStateOf(false) }
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = System.currentTimeMillis())
val nextDatePickerState = rememberDatePickerState(initialSelectedDateMillis = System.currentTimeMillis())
@@ -232,6 +292,25 @@ fun TaskDetailSheet(
)
}
+ if (showProjectPicker) {
+ ProjectPickerDialog(
+ projects = availableProjects,
+ onDismiss = { showProjectPicker = false },
+ onSelect = { projectId -> onSaveProject(projectId); showProjectPicker = false },
+ onClear = { onSaveProject(""); showProjectPicker = false },
+ onCreate = { name, color -> onCreateProject(name, color); showProjectPicker = false }
+ )
+ }
+
+ if (showLabelEditor) {
+ LabelEditorDialog(
+ initial = labels,
+ knownLabels = knownLabels,
+ onDismiss = { showLabelEditor = false },
+ onSave = { newLabels -> onSaveLabels(newLabels); showLabelEditor = false }
+ )
+ }
+
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
@@ -298,6 +377,16 @@ fun TaskDetailSheet(
label = { Text(formatNextDateLabel(nextDate), 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))
@@ -403,3 +492,12 @@ private fun isoDateFromMillis(millis: Long): String {
cal.timeInMillis = millis
return "%04d-%02d-%02d".format(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH))
}
+
+private val LABEL_COLOR_PALETTE = listOf(
+ "#3B82F6", "#EF4444", "#F59E0B", "#10B981", "#8B5CF6", "#EC4899", "#6B7280"
+)
+
+/** 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 =
+ LABEL_COLOR_PALETTE[Math.floorMod(name.hashCode(), LABEL_COLOR_PALETTE.size)]