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
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:
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()
|
||||
|
||||
Reference in new issue
Block a user