diff options
Diffstat (limited to 'android/app')
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)] |
