summaryrefslogtreecommitdiff
path: root/android/app/src/main/java/org/terst
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src/main/java/org/terst')
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/RecurrenceEditDialog.kt91
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt342
2 files changed, 334 insertions, 99 deletions
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<Int>) -> 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<TaskDetail?>(null) }
+ var title by remember { mutableStateOf(initialTitle) }
+ var description by remember { mutableStateOf("") }
+ var dueDate by remember { mutableStateOf(initialDueDate) }
+ var recurrence by remember { mutableStateOf<TaskRecurrence?>(null) }
+ var nextDate by remember { mutableStateOf<String?>(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<Int>) -> 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))
+}