fix timers
lint / ruff (push) Failing after 15s
tests / desktop (push) Successful in 21s
tests / web (push) Successful in 46s
docker / image (push) Failing after 1m31s
tests / android-core (push) Successful in 1m5s
tests / backend (push) Successful in 2m33s
android / apk (push) Successful in 4m34s

This commit is contained in:
timka123 committed 2026-10-27 18:52:54 +03:00
1 parent ff77d8f89f
commit 157a113c39
30 files changed
+3331 -419

No files matched your search

@@ -82,8 +82,19 @@ class KadenceRepository(val api: KadenceApi) {
suspend fun resume(): ApiResult<TimerStateDto> = api.post("/api/timer/resume")
suspend fun complete(autoNext: Boolean = true): ApiResult<TimerStateDto> =
api.post("/api/timer/complete", body("auto_next" to autoNext))
/**
* @param sessionId the interval the caller believes it is finishing. The server
* ignores a call that names an interval which is no longer running — which is
* what stops two clients reaching zero in the same second from completing the
* break the first one's `auto_next` has just begun. Null means unconditional.
*/
suspend fun complete(
autoNext: Boolean = true,
sessionId: Long? = null,
): ApiResult<TimerStateDto> = api.post(
"/api/timer/complete",
body("auto_next" to autoNext, "session_id" to sessionId),
)
suspend fun abandon(reason: String? = null): ApiResult<TimerStateDto> =
api.post("/api/timer/abandon", body("reason" to reason))
@@ -98,7 +109,25 @@ class KadenceRepository(val api: KadenceApi) {
// ---- tasks --------------------------------------------------------------
suspend fun tasks(): ApiResult<TaskListDto> = api.get("/api/tasks")
/**
* The task list.
*
* The calendar needs history — a past month is only worth looking at if the
* work that finished is on it, and laundry slots go `stale` rather than done
* — so it asks for the archive too. The defaults keep every existing caller
* byte-identical, and both views filter defensively anyway, so whichever
* payload was cached last is still rendered correctly.
*/
suspend fun tasks(
includeDone: Boolean = false,
includeStale: Boolean = false,
): ApiResult<TaskListDto> = api.get(
if (includeDone || includeStale) {
"/api/tasks?include_done=$includeDone&include_stale=$includeStale"
} else {
"/api/tasks"
},
)
/** Fans out to Jira, Todoist, Google and the laundry site; slow by nature. */
suspend fun refreshTasks(): ApiResult<RefreshResultDto> = api.post("/api/tasks/refresh")
@@ -20,6 +20,7 @@ package tech.kadence.android.data.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import tech.kadence.core.CalendarItem
@Serializable
data class TaskRefDto(
@@ -63,20 +64,25 @@ data class TimerStateDto(
@SerialName("timer_enabled") val timerEnabled: Boolean = true,
)
/**
* A task, and — via [CalendarItem] — something the month grid can lay out
* directly, the way [KadenceConfig] is an `AuthConfig`. The alternative was a
* mapping layer between the wire and the grid that would only ever copy fields.
*/
@Serializable
data class TaskDto(
val id: Long = 0,
val source: String = "",
@SerialName("source_label") val sourceLabel: String = "",
override val id: Long = 0,
override val source: String = "",
@SerialName("source_label") override val sourceLabel: String = "",
@SerialName("external_id") val externalId: String = "",
val title: String = "",
val description: String? = null,
val url: String? = null,
val project: String? = null,
override val project: String? = null,
val status: String? = null,
val priority: String? = null,
@SerialName("due_date") val dueDate: String? = null,
@SerialName("start_date") val startDate: String? = null,
@SerialName("due_date") override val dueDate: String? = null,
@SerialName("start_date") override val startDate: String? = null,
val labels: List<String> = emptyList(),
@SerialName("is_done") val isDone: Boolean = false,
val pinned: Boolean = false,
@@ -84,7 +90,7 @@ data class TaskDto(
@SerialName("estimated_pomodoros") val estimatedPomodoros: Int? = null,
@SerialName("days_overdue") val daysOverdue: Int = 0,
val bucket: String = "someday",
)
) : CalendarItem
@Serializable
data class TaskListDto(
@@ -75,6 +75,28 @@ class KadencePrefs(context: Context) {
get() = prefs.getLong(KEY_UPDATE_CHECKED_AT, 0L)
set(value) = prefs.edit().putLong(KEY_UPDATE_CHECKED_AT, value).apply()
/**
* How the Tasks screen was last left, deliberately outside [KadenceConfig]
* for the same reason as the update bookkeeping above: a view toggle or a
* filter checkbox must not push the config StateFlow and recompose every
* screen in the app. Nothing outside that one screen reads them, and
* `TimerService` certainly does not.
*/
var tasksCalendarView: Boolean
get() = prefs.getBoolean(KEY_TASKS_CALENDAR, false)
set(value) = prefs.edit().putBoolean(KEY_TASKS_CALENDAR, value).apply()
var tasksShowLong: Boolean
get() = prefs.getBoolean(KEY_TASKS_SHOW_LONG, false)
set(value) = prefs.edit().putBoolean(KEY_TASKS_SHOW_LONG, value).apply()
/** Hidden source/project keys — never the visible ones. See `isHidden`. */
var tasksHiddenKeys: Set<String>
// The returned set is owned by SharedPreferences and must not be handed
// out or mutated; copy it on the way through.
get() = prefs.getStringSet(KEY_TASKS_HIDDEN, emptySet())?.toSet() ?: emptySet()
set(value) = prefs.edit().putStringSet(KEY_TASKS_HIDDEN, value.toSet()).apply()
fun update(transform: (KadenceConfig) -> KadenceConfig) {
val next = transform(_config.value)
prefs.edit().apply {
@@ -105,6 +127,9 @@ class KadencePrefs(context: Context) {
private const val KEY_ONBOARDED = "onboarded"
private const val KEY_UPDATE_SKIPPED = "update_skipped_version"
private const val KEY_UPDATE_CHECKED_AT = "update_checked_at"
private const val KEY_TASKS_CALENDAR = "tasks_calendar_view"
private const val KEY_TASKS_SHOW_LONG = "tasks_show_long"
private const val KEY_TASKS_HIDDEN = "tasks_hidden_keys"
fun normalizeBaseUrl(raw: String): String {
val trimmed = raw.trim().trimEnd('/')
@@ -22,7 +22,10 @@
* whose time is up — it just reports `remaining_seconds = 0`. A client has to call
* `complete`. `desktop/kadence_desktop/controller.py` does the same, with the same
* re-entry guard, because without one a 1 Hz tick at zero fires `complete` every second
* until the reply lands.
* until the reply lands. The guard is per interval id and the call names the interval
* it means to finish: the countdown runs out on every client at once, and a complete
* that arrives after someone else's has already rolled the timer on would otherwise
* finish the interval that rolling on created.
*/
package tech.kadence.android.timer
@@ -147,8 +150,17 @@ class TimerEngine(
*/
private val mutex = Mutex()
/** The `complete` re-entry guard. See the file header. */
private var completing = false
/**
* The interval we have already asked the server to finish. See the file header.
*
* An id, not a bare flag: a poll issued just before the countdown ran out answers
* with that interval still running at 00:00, and a flag cleared by that answer
* sends `complete` a second time — onto whatever `auto_next` has since started,
* which is how a break could end the instant it began. Ids are never reused, so
* the second attempt is never made; a call that *failed* clears it so the zero
* crossing is still retried.
*/
private var completedId: Long? = null
private var pollJob: Job? = null
private var tickJob: Job? = null
@@ -257,10 +269,12 @@ class TimerEngine(
if (remaining != _state.value.remaining) {
_state.value = _state.value.copy(remaining = remaining)
}
if (remaining == 0 && _state.value.isActive && !current.paused && !completing) {
if (remaining == 0 && _state.value.isActive && !current.paused &&
completedId != current.sessionId
) {
// The countdown has run out. The server books the interval and its
// reply decides what follows — an auto-started break, or nothing.
completing = true
completedId = current.sessionId
complete()
}
}
@@ -369,7 +383,6 @@ class TimerEngine(
val current = server.current
if (current == null) {
anchor = null
completing = false
} else {
val changed = anchor?.sessionId != current.id ||
anchor?.paused != current.isPaused ||
@@ -386,7 +399,6 @@ class TimerEngine(
paused = current.isPaused,
)
}
if (current.id != _state.value.sessionId) completing = false
}
_state.value = _state.value.copy(
@@ -467,12 +479,15 @@ class TimerEngine(
_state.value = before.ended()
anchor = null
enqueue({ box, ui -> box.add(OutboxOp.COMPLETE, ui.sessionId) }, before)
completing = false
return@launch
}
// Naming the interval is what makes this safe to send at all: by the time it
// arrives, another client's countdown may already have finished it and started
// the next one, and an unconditional complete would end *that* instead.
val expected = before.sessionId?.takeIf { it != LOCAL_SESSION_ID }
mutex.withLock {
_state.value = _state.value.copy(busy = true)
when (val result = repo.complete(autoNext)) {
when (val result = repo.complete(autoNext, expected)) {
is ApiResult.Ok -> {
val dto = result.value
applyServer(dto, fromCommand = true)
@@ -493,10 +508,14 @@ class TimerEngine(
anchor = null
enqueue({ box, ui -> box.add(OutboxOp.COMPLETE, ui.sessionId) }, before)
}
else -> handleFailure(result)
else -> {
// Nothing was booked, so the interval sitting at 00:00 is still
// owed a complete; let the next tick try again.
if (completedId == before.sessionId) completedId = null
handleFailure(result)
}
}
_state.value = _state.value.copy(busy = false)
completing = false
}
}
@@ -704,7 +723,10 @@ class TimerEngine(
)
OutboxOp.PAUSE -> repo.pause()
OutboxOp.RESUME -> repo.resume()
OutboxOp.COMPLETE -> repo.complete(autoNext = false)
OutboxOp.COMPLETE -> repo.complete(
autoNext = false,
sessionId = entry.anchorSessionId?.takeIf { it != LOCAL_SESSION_ID },
)
OutboxOp.ABANDON -> repo.abandon(entry.reason)
OutboxOp.EXTEND -> repo.extend(entry.minutes)
OutboxOp.INTERRUPT -> repo.interrupt()
@@ -69,6 +69,12 @@ fun KadenceApp(app: KadenceApplication) {
val updateState by app.updates.state.collectAsStateWithLifecycle()
var showOnboarding by remember { mutableStateOf(!config.onboarded) }
// How the Tasks screen was last left. Held here and written straight back to
// prefs rather than kept in `KadenceConfig`, so a filter checkbox does not
// push the config StateFlow and recompose every screen in the app.
var tasksCalendarView by remember { mutableStateOf(app.prefs.tasksCalendarView) }
var tasksShowLong by remember { mutableStateOf(app.prefs.tasksShowLong) }
var tasksHiddenKeys by remember { mutableStateOf(app.prefs.tasksHiddenKeys) }
var showUpdateDialog by remember { mutableStateOf(false) }
var destination by remember { mutableStateOf(Destination.Timer) }
val scope = rememberCoroutineScope()
@@ -157,6 +163,21 @@ fun KadenceApp(app: KadenceApplication) {
cache = app.store.slot(Cached.TASKS, TaskListDto.serializer()),
runningTaskTitle = ui.taskTitle,
intervalRunning = ui.isActive,
calendarView = tasksCalendarView,
onSetCalendarView = { next ->
tasksCalendarView = next
app.prefs.tasksCalendarView = next
},
showLong = tasksShowLong,
onSetShowLong = { next ->
tasksShowLong = next
app.prefs.tasksShowLong = next
},
hiddenKeys = tasksHiddenKeys,
onSetHiddenKeys = { next ->
tasksHiddenKeys = next
app.prefs.tasksHiddenKeys = next
},
onAttach = { taskId ->
// `attach` needs something to attach to: with nothing
// running the server answers "No interval is running."
@@ -0,0 +1,132 @@
/**
* Hide whole categories of work: a source, or one project inside it.
*
* The set it edits holds **hidden** keys, never visible ones, so a Jira project
* that first appears after a refresh shows up rather than being filtered out by a
* set written last week. See `isHidden` in `:core`'s `Calendar.kt`.
*
* Switching a source off disables its projects rather than silently unticking
* them: the parent governs, and switching it back on restores the whole subtree
* instead of leaving ghosts from whatever was unticked before.
*
* `ToggleRow` from the shared primitives is deliberately not used here — its
* explanatory `detail` line is mandatory by design, and twenty Jira projects would
* each demand a sentence nobody wants to read.
*/
package tech.kadence.android.ui.calendar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import tech.kadence.android.ui.components.HorizontalRule
import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.CalendarItem
import tech.kadence.core.buildFilterTree
@Composable
fun FilterPanel(
/** The *unfiltered* list — the counts have to describe what is there. */
items: List<CalendarItem>,
hidden: Set<String>,
onChange: (Set<String>) -> Unit,
modifier: Modifier = Modifier,
) {
val colors = KadenceTheme.colors
val tree = buildFilterTree(items)
Column(modifier.fillMaxWidth()) {
if (tree.isEmpty()) {
Text(
text = "Nothing to filter yet.",
style = KadenceTheme.type.bodySmall,
color = colors.inkMuted,
modifier = Modifier.padding(14.dp),
)
return@Column
}
tree.forEachIndexed { index, node ->
if (index > 0) HorizontalRule()
val sourceHidden = node.key in hidden
FilterRow(
label = node.label,
count = node.count,
checked = !sourceHidden,
bold = true,
onChange = { visible ->
val next = hidden.toMutableSet()
if (visible) {
// Turning a source back on restores its projects with it.
next.remove(node.key)
node.children.forEach { next.remove(it.key) }
} else {
next.add(node.key)
}
onChange(next)
},
)
node.children.forEach { child ->
FilterRow(
label = child.label,
count = child.count,
checked = !sourceHidden && child.key !in hidden,
enabled = !sourceHidden,
indent = true,
onChange = { visible ->
val next = hidden.toMutableSet()
if (visible) next.remove(child.key) else next.add(child.key)
onChange(next)
},
)
}
}
}
}
@Composable
private fun FilterRow(
label: String,
count: Int,
checked: Boolean,
onChange: (Boolean) -> Unit,
bold: Boolean = false,
indent: Boolean = false,
enabled: Boolean = true,
) {
val colors = KadenceTheme.colors
Row(
Modifier
.fillMaxWidth()
.clickable(enabled = enabled) { onChange(!checked) }
.padding(start = if (indent) 32.dp else 8.dp, end = 14.dp, top = 2.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Checkbox(checked = checked, onCheckedChange = onChange, enabled = enabled)
Text(
text = label,
style = KadenceTheme.type.body,
fontWeight = if (bold) FontWeight.SemiBold else null,
color = if (enabled) colors.ink else colors.inkFaint,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = "$count",
style = KadenceTheme.type.caption,
color = colors.inkFaint,
)
}
}
@@ -0,0 +1,258 @@
/**
* The month, as bars from start date to deadline.
*
* Built like a printed table rather than a grid of cards: a hairline between the
* cells, tabular day figures, and exactly one accent — today.
*
* Bars are coloured by **state, not by source**. Five saturated fills across a
* month grid would read as Material rather than Kadence, and five categorical
* hues cannot clear the data-viz palette validator's all-pairs normal-vision
* floor anyway. Identity comes from the title and from the filter; colour is kept
* for what changes how you act on a task — done, overdue, or still waiting.
*
* A lane is a `Row` of weighted cells rather than an absolutely positioned strip:
* the bars are already clipped to the week by `layoutMonth`, so a bar is simply a
* cell of `weight(span)` with `Spacer`s either side. No offset arithmetic, and it
* reflows for free at any width.
*/
package tech.kadence.android.ui.calendar
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import tech.kadence.android.data.dto.TaskDto
import tech.kadence.android.ui.components.HorizontalRule
import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.WeekLayout
import tech.kadence.core.weekdayLabels
private val BAR_HEIGHT = 18.dp
private val BAR_GAP = 3.dp
private val ROW_MIN = 74.dp
@Composable
fun MonthGrid(
weeks: List<WeekLayout>,
/** 1-12 — days outside it are drawn faint. */
month: Int,
today: String,
tasksById: Map<Long, TaskDto>,
selectedDay: String?,
weekStartsMonday: Boolean,
onSelectDay: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val colors = KadenceTheme.colors
Column(modifier.fillMaxWidth()) {
Row(
Modifier
.fillMaxWidth()
.background(colors.surfaceSunken)
.padding(vertical = 5.dp),
) {
weekdayLabels(weekStartsMonday).forEach { name ->
Text(
text = name.take(2),
style = KadenceTheme.type.caption,
color = colors.inkMuted,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f),
)
}
}
HorizontalRule()
weeks.forEachIndexed { index, week ->
if (index > 0) HorizontalRule()
WeekRow(
week = week,
month = month,
today = today,
tasksById = tasksById,
selectedDay = selectedDay,
onSelectDay = onSelectDay,
)
}
}
}
@Composable
private fun WeekRow(
week: WeekLayout,
month: Int,
today: String,
tasksById: Map<Long, TaskDto>,
selectedDay: String?,
onSelectDay: (String) -> Unit,
) {
val colors = KadenceTheme.colors
Box(Modifier.fillMaxWidth().heightIn(min = ROW_MIN)) {
// The cells themselves: the tap targets and the selection tint, behind
// everything. The bars drawn over them carry no pointer modifiers, so
// they never swallow a tap meant for the day.
Row(Modifier.matchParentSize()) {
week.dates.forEach { date ->
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.background(if (date == selectedDay) colors.accentSoft else Color.Transparent)
.clickable { onSelectDay(date) },
)
}
}
Column(Modifier.fillMaxWidth().padding(bottom = 4.dp)) {
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
week.dates.forEach { date ->
val outside = date.substring(5, 7).toInt() != month
Text(
text = date.substring(8, 10).trimStart('0'),
style = KadenceTheme.type.caption,
color = when {
date == today -> colors.accent
outside -> colors.inkFaint
else -> colors.inkMuted
},
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f),
)
}
}
repeat(week.lanes) { lane ->
Row(Modifier.fillMaxWidth().height(BAR_HEIGHT)) {
var column = 0
week.bars
.filter { it.lane == lane }
.sortedBy { it.startCol }
.forEach { bar ->
if (bar.startCol > column) {
Spacer(Modifier.weight((bar.startCol - column).toFloat()))
}
val task = tasksById[bar.id]
val span = (bar.endCol - bar.startCol + 1).toFloat()
if (task == null) {
Spacer(Modifier.weight(span))
} else {
TaskBar(
task = task,
today = today,
continuesLeft = bar.continuesLeft,
continuesRight = bar.continuesRight,
modifier = Modifier.weight(span),
)
}
column = bar.endCol + 1
}
if (column < 7) Spacer(Modifier.weight((7 - column).toFloat()))
}
Spacer(Modifier.height(BAR_GAP))
}
if (week.hasOverflow) {
Row(Modifier.fillMaxWidth()) {
week.overflow.forEach { count ->
Text(
text = if (count > 0) "+$count" else "",
style = KadenceTheme.type.caption,
color = colors.inkMuted,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
/**
* One bar.
*
* `daysOverdue` and `bucket` are computed by the backend against *today*, so a
* stale laundry slot from March claims to be two hundred days overdue forever.
* Neither is used here: lateness is decided from the task's own due date, and only
* for something still open and still offered by its source.
*/
@Composable
private fun TaskBar(
task: TaskDto,
today: String,
continuesLeft: Boolean,
continuesRight: Boolean,
modifier: Modifier = Modifier,
) {
val colors = KadenceTheme.colors
val late = !task.isDone && !task.stale && (task.dueDate?.let { it < today } == true)
val muted = task.isDone || task.stale
val square = 0.dp
val round = 4.dp
Box(
modifier
.fillMaxHeight()
.padding(horizontal = 1.dp)
.clip(
RoundedCornerShape(
topStart = if (continuesLeft) square else round,
bottomStart = if (continuesLeft) square else round,
topEnd = if (continuesRight) square else round,
bottomEnd = if (continuesRight) square else round,
),
)
.background(colors.surfaceSunken),
contentAlignment = Alignment.CenterStart,
) {
Row(
Modifier.fillMaxSize().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (!continuesLeft) {
Box(
Modifier
.width(2.dp)
.fillMaxHeight(0.6f)
.clip(RoundedCornerShape(1.dp))
.background(if (late) colors.clay else if (muted) colors.inkFaint else colors.inkMuted),
)
}
Text(
text = task.title,
style = KadenceTheme.type.caption,
color = when {
muted -> colors.inkFaint
late -> colors.clay
else -> colors.ink
},
textDecoration = if (task.isDone) TextDecoration.LineThrough else null,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -0,0 +1,134 @@
/**
* One task, as a row.
*
* Moved out of `TasksScreen.kt` unchanged when that screen grew a calendar beside
* the list: the calendar's day detail renders the same row, so a task reads
* identically wherever you meet it.
*
* Read-only, and that is the product decision rather than a shortcut: tapping it
* **opens the task where it lives**, which is where closing it happens; "Track"
* attaches it to the running interval, which is the one thing that belongs to
* Kadence.
*/
package tech.kadence.android.ui.tasks
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import tech.kadence.android.data.dto.TaskDto
import tech.kadence.android.ui.components.Eyebrow
import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.dueLabel
@Composable
internal fun TaskRow(
task: TaskDto,
isRunning: Boolean,
anyIntervalRunning: Boolean,
onOpen: () -> Unit,
onTrack: () -> Unit,
) {
val colors = KadenceTheme.colors
val due = dueLabel(task.dueDate, task.daysOverdue)
val linkable = !task.url.isNullOrBlank()
Row(
Modifier
.fillMaxWidth()
// A local task has no URL and nothing to open; making it look tappable
// and then doing nothing is worse than it being plainly inert.
.then(if (linkable) Modifier.clickable(onClick = onOpen) else Modifier)
.padding(start = 14.dp, end = 4.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(Modifier.weight(1f)) {
Text(
text = task.title,
style = KadenceTheme.type.body,
color = if (task.stale || task.isDone) colors.inkMuted else colors.ink,
textDecoration = if (task.isDone) TextDecoration.LineThrough else null,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Eyebrow(task.sourceLabel.ifBlank { task.source })
if (due.isNotBlank()) {
Text(
text = due,
style = KadenceTheme.type.caption,
// Overdue is the one case worth colouring; everything else is
// a date and a date is not an alarm. `daysOverdue` is computed
// against *today*, so it only means anything for something
// still open — a stale laundry slot carries one forever.
color = if (task.daysOverdue > 0 && !task.isDone && !task.stale) {
colors.clay
} else {
colors.inkFaint
},
)
}
task.project?.takeIf { it.isNotBlank() }?.let { project ->
Text(
text = project,
style = KadenceTheme.type.caption,
color = colors.inkFaint,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
if (isRunning) {
Text(
text = "running",
style = KadenceTheme.type.caption,
color = colors.accent,
modifier = Modifier.padding(end = 10.dp),
)
} else {
TextButton(onClick = onTrack, contentPadding = PaddingValues(horizontal = 10.dp)) {
// "Focus" when the tap will begin an interval on this task, "Track"
// when it only re-points the one already running.
Text(
text = if (anyIntervalRunning) "Track" else "Focus",
style = KadenceTheme.type.button,
color = colors.accent,
)
}
}
}
}
/**
* Open the task in whatever app or browser owns it.
*
* `url` is absent for local tasks, and for a source that did not supply one; the row
* is not clickable in that case, so this is only ever reached with something to open.
* Still guarded, because an unparseable URL from a third-party API must not crash the
* list.
*/
internal fun openTask(context: android.content.Context, task: TaskDto) {
val url = task.url?.takeIf { it.isNotBlank() } ?: return
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
.onFailure { android.util.Log.w("Kadence", "cannot open $url: ${it.message}") }
}
@@ -1,85 +1,119 @@
/**
* Every todo you own, grouped the way the other clients group them.
* Every todo you own — as a list, or as a month.
*
* Read-only, and that is the product decision rather than a shortcut: **you cannot tick
* a task off here.** Completion belongs to the tool the task lives in — close it in
* Jira, Todoist or Google Tasks and the next refresh notices. Two places to mark
* something done is two places to be wrong.
* Read-only, and that is the product decision rather than a shortcut: **you cannot
* tick a task off here.** Completion belongs to the tool the task lives in — close
* it in Jira, Todoist or Google Tasks and the next refresh notices. Two places to
* mark something done is two places to be wrong.
*
* So a row has exactly two actions and they follow from that. Tapping it **opens the
* task where it lives**, which is where closing it happens; "Track" attaches it to the
* running interval, which is the one thing that belongs to Kadence. A task with no
* `url` — a local one, or a source that supplied none — is simply not tappable rather
* than tappable-and-inert.
* The screen asks the backend for **done and stale rows too**, because a past
* month is only worth looking at if the work that finished is on it, and laundry
* slots go `stale` rather than done. The list therefore filters those out itself
* rather than trusting the URL the payload came from — which also means the single
* `Cached.TASKS` slot stays correct whichever view wrote it last.
*
* Bucket order and labels come from `:core`'s `BUCKET_ORDER` / `BUCKET_LABEL`, so the
* phone cannot drift from the web app's wording — including the deliberate choice of
* "Would be nice to start" over the desktop's "In progress".
* Bucket order and labels come from `:core`, and so does the whole calendar
* layout, so the phone cannot drift from the web app.
*
* One Compose constraint worth not re-learning: this is a single `LazyColumn`, and
* both bodies contribute `item`s to *it*. The month grid is a plain `Column` of
* `Row`s inside one item — never a nested `LazyVerticalGrid`, which throws on
* same-axis nested scrolling.
*/
package tech.kadence.android.ui.tasks
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.CircularProgressIndicator
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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.dto.TaskDto
import tech.kadence.android.data.dto.TaskListDto
import tech.kadence.android.data.net.ApiResult
import tech.kadence.android.data.store.CacheSlot
import tech.kadence.android.ui.Loadable
import tech.kadence.android.ui.calendar.FilterPanel
import tech.kadence.android.ui.calendar.MonthGrid
import tech.kadence.android.ui.components.Banner
import tech.kadence.android.ui.components.Eyebrow
import tech.kadence.android.ui.components.GhostButton
import tech.kadence.android.ui.components.HorizontalRule
import tech.kadence.android.ui.components.PageHeader
import tech.kadence.android.ui.components.Panel
import tech.kadence.android.ui.components.PresetChip
import tech.kadence.android.ui.components.SectionTitle
import tech.kadence.android.data.store.CacheSlot
import tech.kadence.android.ui.rememberLoad
import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.BUCKET_LABEL
import tech.kadence.core.BUCKET_ORDER
import tech.kadence.core.dueLabel
import tech.kadence.core.buildModel
import tech.kadence.core.itemsOnDay
import tech.kadence.core.layoutMonth
import tech.kadence.core.monthGrid
import tech.kadence.core.shiftMonth
import tech.kadence.core.todayISO
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
/**
* Monday-first, hardcoded on both clients for now.
*
* `week_starts_monday` is in the settings document, but `AppSettingsDto` does not
* carry it and this screen loads no settings — so honouring it on the web alone is
* how the two clients start disagreeing about what a week is. The backend default
* is Monday anyway.
*/
private const val WEEK_STARTS_MONDAY = true
@Composable
fun TasksScreen(
repo: KadenceRepository,
/** Yesterday's list, so a phone with no signal is not an empty screen. */
cache: CacheSlot<tech.kadence.android.data.dto.TaskListDto>? = null,
cache: CacheSlot<TaskListDto>? = null,
runningTaskTitle: String?,
/** An interval is running — with or without a task attached to it. */
intervalRunning: Boolean,
calendarView: Boolean,
onSetCalendarView: (Boolean) -> Unit,
showLong: Boolean,
onSetShowLong: (Boolean) -> Unit,
hiddenKeys: Set<String>,
onSetHiddenKeys: (Set<String>) -> Unit,
onAttach: (Long) -> Unit,
) {
val colors = KadenceTheme.colors
val context = LocalContext.current
val scope = rememberCoroutineScope()
val loader = rememberLoad(cache = cache) { repo.tasks() }
val loader = rememberLoad(cache = cache) {
repo.tasks(includeDone = true, includeStale = true)
}
var refreshing by remember { mutableStateOf(false) }
var refreshNote by remember { mutableStateOf<String?>(null) }
val today = remember { todayISO() }
var cursorYear by rememberSaveable { mutableStateOf(LocalDate.now().year) }
var cursorMonth by rememberSaveable { mutableStateOf(LocalDate.now().monthValue) }
var selectedDay by rememberSaveable { mutableStateOf<String?>(null) }
var filterOpen by rememberSaveable { mutableStateOf(false) }
fun refresh() {
refreshing = true
refreshNote = null
@@ -106,23 +140,21 @@ fun TasksScreen(
}
val state = loader.value
val all = (state as? Loadable.Loaded)?.value?.tasks.orEmpty()
// Always, regardless of which URL answered: that is what keeps one cache slot
// correct for two views.
val open = all.filter { !it.isDone && !it.stale }
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 20.dp,
end = 20.dp,
top = 24.dp,
bottom = 32.dp,
),
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
val total = (state as? Loadable.Loaded)?.value?.tasks?.size
PageHeader(
eyebrow = "Everything open",
title = "Tasks",
caption = total?.let { "$it across your sources" },
caption = if (state is Loadable.Loaded) "${open.size} across your sources" else null,
) {
GhostButton(
text = if (refreshing) "Refreshing…" else "Refresh sources",
@@ -132,6 +164,13 @@ fun TasksScreen(
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PresetChip(text = "List", selected = !calendarView, onClick = { onSetCalendarView(false) })
PresetChip(text = "Calendar", selected = calendarView, onClick = { onSetCalendarView(true) })
}
}
refreshNote?.let { note ->
item { Banner(text = note, actionLabel = "Retry", onAction = ::refresh) }
}
@@ -155,148 +194,239 @@ fun TasksScreen(
item { Banner(text = stale, actionLabel = "Retry", onAction = loader::reload) }
}
val byBucket = state.value.tasks.groupBy { it.bucket }
// BUCKET_ORDER first, then anything the backend invented later — an
// unknown bucket must still be visible rather than silently dropped.
val buckets = BUCKET_ORDER.filter { byBucket.containsKey(it) } +
byBucket.keys.filterNot { it in BUCKET_ORDER }.sorted()
if (state.value.tasks.isEmpty()) {
item {
Panel {
Text(
text = "Nothing open. Either you are done, or no source " +
"is configured yet — the backend's Settings page " +
"is where they go.",
style = KadenceTheme.type.bodySmall,
color = colors.inkMuted,
)
}
}
}
buckets.forEach { bucket ->
val tasks = byBucket[bucket].orEmpty()
item(key = "head-$bucket") {
SectionTitle(
label = BUCKET_LABEL[bucket] ?: bucket,
trailing = "${tasks.size}",
)
}
item(key = "body-$bucket") {
Panel(padding = 0.dp) {
tasks.forEachIndexed { index, task ->
if (index > 0) HorizontalRule()
TaskRow(
task = task,
isRunning = task.title == runningTaskTitle,
anyIntervalRunning = intervalRunning,
// Tapping the row opens the task where it lives —
// Jira, Todoist, Google Tasks. That is where it
// gets *closed*, and this app deliberately cannot
// close it, so sending you there is the whole of
// what a tap should do.
onOpen = { openTask(context, task) },
// Attaching is the separate, explicit action.
onTrack = { onAttach(task.id) },
)
}
}
}
if (calendarView) {
calendarBody(
all = all,
hiddenKeys = hiddenKeys,
onSetHiddenKeys = onSetHiddenKeys,
showLong = showLong,
onSetShowLong = onSetShowLong,
today = today,
year = cursorYear,
month = cursorMonth,
onShiftMonth = { delta ->
val (y, m) = shiftMonth(cursorYear, cursorMonth, delta)
cursorYear = y
cursorMonth = m
selectedDay = null
},
onToday = {
cursorYear = LocalDate.now().year
cursorMonth = LocalDate.now().monthValue
selectedDay = null
},
selectedDay = selectedDay,
onSelectDay = { selectedDay = if (it == selectedDay) null else it },
filterOpen = filterOpen,
onToggleFilter = { filterOpen = !filterOpen },
runningTaskTitle = runningTaskTitle,
intervalRunning = intervalRunning,
onOpenTask = { openTask(context, it) },
onAttach = onAttach,
)
} else {
listBody(
tasks = open,
runningTaskTitle = runningTaskTitle,
intervalRunning = intervalRunning,
onOpenTask = { openTask(context, it) },
onAttach = onAttach,
)
}
}
}
}
}
@Composable
private fun TaskRow(
task: TaskDto,
isRunning: Boolean,
anyIntervalRunning: Boolean,
onOpen: () -> Unit,
onTrack: () -> Unit,
/** The original view: grouped by deadline bucket. */
private fun LazyListScope.listBody(
tasks: List<TaskDto>,
runningTaskTitle: String?,
intervalRunning: Boolean,
onOpenTask: (TaskDto) -> Unit,
onAttach: (Long) -> Unit,
) {
val colors = KadenceTheme.colors
val due = dueLabel(task.dueDate, task.daysOverdue)
val linkable = !task.url.isNullOrBlank()
val byBucket = tasks.groupBy { it.bucket }
// BUCKET_ORDER first, then anything the backend invented later — an unknown
// bucket must still be visible rather than silently dropped.
val buckets = BUCKET_ORDER.filter { byBucket.containsKey(it) } +
byBucket.keys.filterNot { it in BUCKET_ORDER }.sorted()
Row(
Modifier
.fillMaxWidth()
// A local task has no URL and nothing to open; making it look tappable
// and then doing nothing is worse than it being plainly inert.
.then(if (linkable) Modifier.clickable(onClick = onOpen) else Modifier)
.padding(start = 14.dp, end = 4.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(Modifier.weight(1f)) {
Text(
text = task.title,
style = KadenceTheme.type.body,
color = if (task.stale) colors.inkMuted else colors.ink,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Eyebrow(task.sourceLabel.ifBlank { task.source })
if (due.isNotBlank()) {
Text(
text = due,
style = KadenceTheme.type.caption,
// Overdue is the one case worth colouring; everything else is
// a date and a date is not an alarm.
color = if (task.daysOverdue > 0) colors.clay else colors.inkFaint,
)
}
task.project?.takeIf { it.isNotBlank() }?.let { project ->
Text(
text = project,
style = KadenceTheme.type.caption,
color = colors.inkFaint,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
if (isRunning) {
Text(
text = "running",
style = KadenceTheme.type.caption,
color = colors.accent,
modifier = Modifier.padding(end = 10.dp),
)
} else {
TextButton(onClick = onTrack, contentPadding = PaddingValues(horizontal = 10.dp)) {
// "Focus" when the tap will begin an interval on this task, "Track"
// when it only re-points the one already running.
if (tasks.isEmpty()) {
item {
Panel {
Text(
text = if (anyIntervalRunning) "Track" else "Focus",
style = KadenceTheme.type.button,
color = colors.accent,
text = "Nothing open. Either you are done, or no source is " +
"configured yet — the backend's Settings page is where they go.",
style = KadenceTheme.type.bodySmall,
color = KadenceTheme.colors.inkMuted,
)
}
}
}
buckets.forEach { bucket ->
val inBucket = byBucket[bucket].orEmpty()
item(key = "head-$bucket") {
SectionTitle(label = BUCKET_LABEL[bucket] ?: bucket, trailing = "${inBucket.size}")
}
item(key = "body-$bucket") {
Panel(padding = 0.dp) {
inBucket.forEachIndexed { index, task ->
if (index > 0) HorizontalRule()
TaskRow(
task = task,
isRunning = task.title == runningTaskTitle,
anyIntervalRunning = intervalRunning,
onOpen = { onOpenTask(task) },
onTrack = { onAttach(task.id) },
)
}
}
}
}
}
/**
* Open the task in whatever app or browser owns it.
*
* `url` is absent for local tasks, and for a source that did not supply one; the row
* is not clickable in that case, so this is only ever reached with something to open.
* Still guarded, because an unparseable URL from a third-party API must not crash the
* list.
*/
private fun openTask(context: android.content.Context, task: TaskDto) {
val url = task.url?.takeIf { it.isNotBlank() } ?: return
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
.onFailure { android.util.Log.w("Kadence", "cannot open $url: ${it.message}") }
private val MONTH_LABEL = DateTimeFormatter.ofPattern("LLLL yyyy")
private fun LazyListScope.calendarBody(
all: List<TaskDto>,
hiddenKeys: Set<String>,
onSetHiddenKeys: (Set<String>) -> Unit,
showLong: Boolean,
onSetShowLong: (Boolean) -> Unit,
today: String,
year: Int,
month: Int,
onShiftMonth: (Int) -> Unit,
onToday: () -> Unit,
selectedDay: String?,
onSelectDay: (String) -> Unit,
filterOpen: Boolean,
onToggleFilter: () -> Unit,
runningTaskTitle: String?,
intervalRunning: Boolean,
onOpenTask: (TaskDto) -> Unit,
onAttach: (Long) -> Unit,
) {
val model = buildModel(all, hiddenKeys, showLong)
val weeks = monthGrid(year, month, WEEK_STARTS_MONDAY)
// Showing long bars needs the room they take, or they simply push the day's
// real work into "+N" and the month gets less informative, not more.
val layouts = layoutMonth(weeks, model.visible, maxLanes = if (showLong) 4 else 3)
val byId = model.visible.associateBy { it.id }
item(key = "cal-toolbar") {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
GhostButton(text = "‹", onClick = { onShiftMonth(-1) })
Text(
text = YearMonth.of(year, month).format(MONTH_LABEL),
style = KadenceTheme.type.statSmall,
color = KadenceTheme.colors.ink,
modifier = Modifier.weight(1f),
)
GhostButton(text = "›", onClick = { onShiftMonth(1) })
GhostButton(text = "Today", onClick = onToday)
}
}
item(key = "cal-filters") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PresetChip(
text = if (hiddenKeys.isEmpty()) "Filter" else "Filter (${hiddenKeys.size})",
selected = filterOpen,
onClick = onToggleFilter,
)
PresetChip(
text = "Long tasks (${model.longCount})",
selected = showLong,
onClick = { onSetShowLong(!showLong) },
)
}
}
if (filterOpen) {
item(key = "cal-filter-panel") {
Panel(padding = 0.dp) {
FilterPanel(items = all, hidden = hiddenKeys, onChange = onSetHiddenKeys)
}
}
}
item(key = "cal-grid") {
Panel(padding = 0.dp) {
MonthGrid(
weeks = layouts,
month = month,
today = today,
tasksById = byId,
selectedDay = selectedDay,
weekStartsMonday = WEEK_STARTS_MONDAY,
onSelectDay = onSelectDay,
)
}
}
// The tapped day's work, under the grid. Items of the *outer* LazyColumn, not
// a nested lazy list.
selectedDay?.let { day ->
val onDay = itemsOnDay(model.visible, day)
item(key = "cal-day-head") {
SectionTitle(
label = LocalDate.parse(day).format(DAY_LABEL),
trailing = "${onDay.size}",
)
}
item(key = "cal-day-body") {
Panel(padding = 0.dp) {
if (onDay.isEmpty()) {
Text(
text = "Nothing on this day.",
style = KadenceTheme.type.bodySmall,
color = KadenceTheme.colors.inkMuted,
modifier = Modifier.padding(14.dp),
)
} else {
onDay.forEachIndexed { index, task ->
if (index > 0) HorizontalRule()
TaskRow(
task = task,
isRunning = task.title == runningTaskTitle,
anyIntervalRunning = intervalRunning,
onOpen = { onOpenTask(task) },
onTrack = { onAttach(task.id) },
)
}
}
}
}
}
// The tasks a calendar cannot place: no due date, so no column to sit in.
// Jira keeps the same affordance and calls it the backlog.
if (model.undated.isNotEmpty()) {
item(key = "cal-undated-head") {
SectionTitle(label = "No due date", trailing = "${model.undated.size}")
}
item(key = "cal-undated-body") {
Panel(padding = 0.dp) {
model.undated.forEachIndexed { index, task ->
if (index > 0) HorizontalRule()
TaskRow(
task = task,
isRunning = task.title == runningTaskTitle,
anyIntervalRunning = intervalRunning,
onOpen = { onOpenTask(task) },
onTrack = { onAttach(task.id) },
)
}
}
}
}
}
private val DAY_LABEL = DateTimeFormatter.ofPattern("EEEE, d MMMM")
@@ -79,6 +79,8 @@ class TimerEngineTest {
path.endsWith("/api/timer/pause") -> running(pauseReplyStatus, remaining = 900)
path.endsWith("/api/timer/resume") -> running("running")
path.endsWith("/api/timer/extend") -> running("running", 1404, 1800)
path.endsWith("/api/timer/complete") ->
fixture("timer_complete_reply.json")
else -> fixture("timer_state_running.json")
}
return MockResponse.Builder()
@@ -140,6 +142,30 @@ class TimerEngineTest {
assertTrue(engine.state.value.interruptions >= 1)
}
@Test
fun `complete names the interval it is finishing`() {
// Every client runs its own countdown, so they all reach zero together. An
// unconditional complete from the slowest of them lands on the break the
// fastest one's auto-start has just created and ends it on the spot — which
// is what made breaks look like they turned straight into focus. Naming the
// interval is what lets the server ignore the late call.
val engine = engine()
runBlocking { engine.complete().join() }
val sent = server.takeRequest().let { first ->
// The first recorded request is the fixture poll from `engine()`.
if (first.url.encodedPath.endsWith("/api/timer/complete")) first
else server.takeRequest()
}
assertEquals("/api/timer/complete", sent.url.encodedPath)
assertTrue(
sent.body?.utf8()?.contains("\"session_id\":412") == true,
"the complete must name interval 412, not just say 'finish whatever is running'",
)
assertEquals(413L, engine.state.value.sessionId, "the auto-started break is adopted")
}
@Test
fun `toggle reads the state it just changed`() {
val engine = engine()