some backend fixes and add android app
This commit is contained in:
69 files changed
+7111
-7
No files matched your search
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!--
|
||||
Kadence never declares VpnService. Android allows exactly one active VPN, and on
|
||||
this phone it belongs to the user's always-on connection. Website blocking reads
|
||||
the browser's URL bar through an accessibility service instead; app blocking polls
|
||||
usage stats. Neither takes the VPN slot. Please keep it that way.
|
||||
-->
|
||||
|
||||
<application
|
||||
android:name=".KadenceApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Kadence">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.Kadence">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package tech.kadence.android
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class KadenceApplication : Application()
|
||||
@@ -0,0 +1,176 @@
|
||||
package tech.kadence.android
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
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.PrimaryButton
|
||||
import tech.kadence.android.ui.components.SectionTitle
|
||||
import tech.kadence.android.ui.components.Stat
|
||||
import tech.kadence.android.ui.components.StatSize
|
||||
import tech.kadence.android.ui.components.TimerDial
|
||||
import tech.kadence.android.ui.components.VerticalRule
|
||||
import tech.kadence.android.ui.theme.KadenceTheme
|
||||
import tech.kadence.core.Kind
|
||||
import tech.kadence.core.KIND_LABEL
|
||||
import tech.kadence.core.humanMinutes
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
KadenceTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = KadenceTheme.colors.paper,
|
||||
) {
|
||||
DesignGallery()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A scratch screen for the design system, standing in until the real Timer screen
|
||||
* lands. It exists so the tokens, the type and the dial can be checked against the web
|
||||
* app side by side on a device.
|
||||
*/
|
||||
@Composable
|
||||
private fun DesignGallery() {
|
||||
val colors = KadenceTheme.colors
|
||||
val planned = 25 * 60
|
||||
|
||||
// A live countdown, so the dial and the fixed-advance clock can be watched ticking.
|
||||
var remaining by remember { mutableIntStateOf(18 * 60 + 24) }
|
||||
var paused by remember { mutableStateOf(false) }
|
||||
var preset by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(paused) {
|
||||
while (!paused) {
|
||||
delay(1000)
|
||||
remaining = if (remaining > 0) remaining - 1 else planned
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(22.dp),
|
||||
) {
|
||||
PageHeader(
|
||||
eyebrow = "Monday, 19 October",
|
||||
title = KIND_LABEL.getValue(Kind.WORK),
|
||||
caption = "Tracking \"Fix the flaky importer\"",
|
||||
) {
|
||||
GhostButton(text = if (paused) "Resume" else "Pause", onClick = { paused = !paused })
|
||||
PrimaryButton(text = "Finish", onClick = { remaining = planned })
|
||||
}
|
||||
|
||||
Panel(padding = 24.dp) {
|
||||
TimerDial(
|
||||
label = KIND_LABEL.getValue(Kind.WORK),
|
||||
remaining = { remaining },
|
||||
plannedSeconds = planned,
|
||||
accent = KadenceTheme.viz.work,
|
||||
sublabel = "Cycle 2 of 4 before a long break",
|
||||
paused = paused,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
listOf("Classic", "Пара", "Custom").forEachIndexed { index, name ->
|
||||
PresetChip(
|
||||
text = name,
|
||||
selected = preset == index,
|
||||
onClick = { preset = index },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionTitle("Today", trailing = "6 of 8")
|
||||
Panel(padding = 0.dp) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Stat(
|
||||
label = "Focus today",
|
||||
value = humanMinutes(155),
|
||||
caption = "35m resting",
|
||||
modifier = Modifier.weight(1f).padding(16.dp),
|
||||
)
|
||||
VerticalRule(height = 70.dp)
|
||||
Stat(
|
||||
label = "Interruptions",
|
||||
value = "3",
|
||||
caption = "counted, not stopped",
|
||||
tabular = true,
|
||||
modifier = Modifier.weight(1f).padding(16.dp),
|
||||
)
|
||||
}
|
||||
HorizontalRule()
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Stat(
|
||||
label = "Score",
|
||||
value = "72",
|
||||
caption = "out of 100",
|
||||
size = StatSize.Large,
|
||||
accent = colors.accent,
|
||||
tabular = true,
|
||||
modifier = Modifier.weight(1f).padding(16.dp),
|
||||
)
|
||||
VerticalRule(height = 84.dp)
|
||||
Stat(
|
||||
label = "Streak",
|
||||
value = "4d",
|
||||
caption = "best 11",
|
||||
size = StatSize.Large,
|
||||
modifier = Modifier.weight(1f).padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SectionTitle("Palette")
|
||||
Panel {
|
||||
Text(
|
||||
text = "The accent is taken from the system palette; paper, ink and the " +
|
||||
"hairlines stay Kadence's. Chart colours never follow the accent.",
|
||||
style = KadenceTheme.type.bodySmall,
|
||||
color = colors.inkMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* The wire shapes.
|
||||
*
|
||||
* The backend has **no response models** — `backend/app/api/schemas.py` only declares
|
||||
* request bodies, and every response is a plain dict built by a service function. So
|
||||
* the only contract is the service code plus `frontend/src/lib/types.ts`, and these DTOs
|
||||
* are reverse-engineered from both.
|
||||
*
|
||||
* That means tolerance is the design, not a nicety: **every field has a default and
|
||||
* every nested object is nullable**, so a backend that grows a key, drops one, or sends
|
||||
* null where a value is declared can never crash the phone. `DtoToleranceTest` asserts
|
||||
* exactly that by mutating committed fixtures.
|
||||
*
|
||||
* Two details worth not re-learning the hard way:
|
||||
* * `TimerStateDto.durations` is in **minutes** (`pomodoro.state()`), not seconds.
|
||||
* * `paused_seconds` exists on the wire but is missing from `types.ts`; the outbox
|
||||
* reconciliation wants it, so it is declared here.
|
||||
*/
|
||||
package tech.kadence.android.data.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TaskRefDto(
|
||||
val source: String = "",
|
||||
@SerialName("external_id") val externalId: String = "",
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TimerSessionDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "work",
|
||||
val status: String = "running",
|
||||
@SerialName("started_at") val startedAt: String? = null,
|
||||
@SerialName("ended_at") val endedAt: String? = null,
|
||||
@SerialName("planned_seconds") val plannedSeconds: Int = 0,
|
||||
@SerialName("elapsed_seconds") val elapsedSeconds: Int = 0,
|
||||
@SerialName("remaining_seconds") val remainingSeconds: Int = 0,
|
||||
@SerialName("paused_seconds") val pausedSeconds: Int = 0,
|
||||
val interruptions: Int = 0,
|
||||
@SerialName("cycle_index") val cycleIndex: Int = 0,
|
||||
val task: TaskRefDto? = null,
|
||||
val note: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TimerStateDto(
|
||||
val current: TimerSessionDto? = null,
|
||||
@SerialName("next_kind") val nextKind: String = "work",
|
||||
@SerialName("completed_today") val completedToday: Int = 0,
|
||||
val goal: Int = 8,
|
||||
@SerialName("cycle_position") val cyclePosition: Int = 0,
|
||||
@SerialName("cycles_before_long_break") val cyclesBeforeLongBreak: Int = 4,
|
||||
/** Minutes per kind, not seconds. */
|
||||
val durations: Map<String, Int> = emptyMap(),
|
||||
@SerialName("server_time") val serverTime: String? = null,
|
||||
/** Only present on a `complete` reply. */
|
||||
val finished: TimerSessionDto? = null,
|
||||
@SerialName("auto_started") val autoStarted: TimerSessionDto? = null,
|
||||
val celebration: String? = null,
|
||||
@SerialName("timer_enabled") val timerEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskDto(
|
||||
val id: Long = 0,
|
||||
val source: String = "",
|
||||
@SerialName("source_label") val sourceLabel: String = "",
|
||||
@SerialName("external_id") val externalId: String = "",
|
||||
val title: String = "",
|
||||
val description: String? = null,
|
||||
val url: String? = null,
|
||||
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,
|
||||
val labels: List<String> = emptyList(),
|
||||
@SerialName("is_done") val isDone: Boolean = false,
|
||||
val pinned: Boolean = false,
|
||||
val stale: Boolean = false,
|
||||
@SerialName("estimated_pomodoros") val estimatedPomodoros: Int? = null,
|
||||
@SerialName("days_overdue") val daysOverdue: Int = 0,
|
||||
val bucket: String = "someday",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskListDto(
|
||||
val date: String = "",
|
||||
val tasks: List<TaskDto> = emptyList(),
|
||||
val counts: Map<String, Int> = emptyMap(),
|
||||
val sources: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RefreshSourceDto(
|
||||
val source: String = "",
|
||||
val label: String = "",
|
||||
val ok: Boolean = true,
|
||||
val error: String? = null,
|
||||
val count: Int = 0,
|
||||
val closed: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RefreshResultDto(
|
||||
@SerialName("refreshed_at") val refreshedAt: String? = null,
|
||||
val date: String = "",
|
||||
val total: Int = 0,
|
||||
val closed: Int = 0,
|
||||
val sources: List<RefreshSourceDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BlockerPolicyDto(
|
||||
val active: Boolean = false,
|
||||
val reason: String = "",
|
||||
val mode: String = "during_work",
|
||||
val enabled: Boolean = false,
|
||||
val strict: Boolean = false,
|
||||
@SerialName("redirect_ip") val redirectIp: String = "127.0.0.1",
|
||||
/** Empty whenever `active` is false — read `all_domains` instead. */
|
||||
val domains: List<String> = emptyList(),
|
||||
/** Likewise empty when inactive. */
|
||||
val apps: List<String> = emptyList(),
|
||||
val unexpandable: List<String> = emptyList(),
|
||||
@SerialName("kill_apps") val killApps: Boolean = true,
|
||||
@SerialName("notify_on_block") val notifyOnBlock: Boolean = true,
|
||||
@SerialName("poll_seconds") val pollSeconds: Int = 5,
|
||||
@SerialName("session_id") val sessionId: Long? = null,
|
||||
/** Always populated, whatever `active` says. */
|
||||
@SerialName("all_domains") val allDomains: List<String> = emptyList(),
|
||||
@SerialName("all_apps") val allApps: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BlockEventDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "app",
|
||||
val target: String = "",
|
||||
val action: String = "blocked",
|
||||
val at: String? = null,
|
||||
val details: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TargetCountDto(val target: String = "", val count: Int = 0)
|
||||
|
||||
@Serializable
|
||||
data class BlockEventsDto(
|
||||
val date: String = "",
|
||||
val total: Int = 0,
|
||||
@SerialName("by_target") val byTarget: List<TargetCountDto> = emptyList(),
|
||||
val events: List<BlockEventDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HourCountDto(val hour: Int = 0, val count: Int = 0)
|
||||
|
||||
@Serializable
|
||||
data class CompletedTaskDto(
|
||||
val id: Long = 0,
|
||||
val title: String = "",
|
||||
val source: String = "",
|
||||
val project: String? = null,
|
||||
val url: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskMinutesDto(val title: String = "", val minutes: Int = 0)
|
||||
|
||||
@Serializable
|
||||
data class DaySessionDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "work",
|
||||
val status: String = "completed",
|
||||
@SerialName("started_at") val startedAt: String? = null,
|
||||
val minutes: Int = 0,
|
||||
val task: String? = null,
|
||||
val interruptions: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DayMetricsDto(
|
||||
val date: String = "",
|
||||
val pomodoros: Int = 0,
|
||||
val goal: Int = 8,
|
||||
@SerialName("goal_pct") val goalPct: Double = 0.0,
|
||||
@SerialName("goal_met") val goalMet: Boolean = false,
|
||||
@SerialName("focus_minutes") val focusMinutes: Int = 0,
|
||||
@SerialName("break_minutes") val breakMinutes: Int = 0,
|
||||
@SerialName("wasted_minutes") val wastedMinutes: Int = 0,
|
||||
val abandoned: Int = 0,
|
||||
@SerialName("breaks_taken") val breaksTaken: Int = 0,
|
||||
val interruptions: Int = 0,
|
||||
@SerialName("completion_rate") val completionRate: Double = 0.0,
|
||||
@SerialName("longest_stretch_minutes") val longestStretchMinutes: Int = 0,
|
||||
@SerialName("first_start") val firstStart: String? = null,
|
||||
@SerialName("last_end") val lastEnd: String? = null,
|
||||
@SerialName("by_hour") val byHour: List<HourCountDto> = emptyList(),
|
||||
@SerialName("peak_hour") val peakHour: Int? = null,
|
||||
@SerialName("tasks_completed") val tasksCompleted: List<CompletedTaskDto> = emptyList(),
|
||||
@SerialName("tasks_completed_count") val tasksCompletedCount: Int = 0,
|
||||
@SerialName("time_by_task") val timeByTask: List<TaskMinutesDto> = emptyList(),
|
||||
@SerialName("blocked_attempts") val blockedAttempts: Int = 0,
|
||||
@SerialName("blocked_top") val blockedTop: List<TargetCountDto> = emptyList(),
|
||||
val sessions: List<DaySessionDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StreakDto(
|
||||
val current: Int = 0,
|
||||
val best: Int = 0,
|
||||
@SerialName("goal_streak") val goalStreak: Int = 0,
|
||||
@SerialName("total_days") val totalDays: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LifetimeDto(
|
||||
val pomodoros: Int = 0,
|
||||
@SerialName("focus_minutes") val focusMinutes: Int = 0,
|
||||
@SerialName("focus_hours") val focusHours: Double = 0.0,
|
||||
@SerialName("tasks_completed") val tasksCompleted: Int = 0,
|
||||
@SerialName("blocked_attempts") val blockedAttempts: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AverageDto(
|
||||
val pomodoros: Double = 0.0,
|
||||
@SerialName("focus_minutes") val focusMinutes: Double = 0.0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ComparisonDto(
|
||||
val yesterday: Int = 0,
|
||||
@SerialName("previous_best") val previousBest: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AchievementDto(
|
||||
val code: String = "",
|
||||
val emoji: String = "",
|
||||
val title: String = "",
|
||||
val detail: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DailyReportDto(
|
||||
val date: String = "",
|
||||
val metrics: DayMetricsDto = DayMetricsDto(),
|
||||
val streak: StreakDto = StreakDto(),
|
||||
@SerialName("average_7d") val average7d: AverageDto = AverageDto(),
|
||||
val lifetime: LifetimeDto = LifetimeDto(),
|
||||
val comparison: ComparisonDto = ComparisonDto(),
|
||||
val achievements: List<AchievementDto> = emptyList(),
|
||||
@SerialName("fun_facts") val funFacts: List<String> = emptyList(),
|
||||
val verdict: String = "",
|
||||
@SerialName("open_tasks") val openTasks: Map<String, Int> = emptyMap(),
|
||||
val score: Double = 0.0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DailyReportEnvelopeDto(
|
||||
val report: DailyReportDto = DailyReportDto(),
|
||||
@SerialName("sent_at") val sentAt: String? = null,
|
||||
@SerialName("telegram_preview") val telegramPreview: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RangeDayDto(
|
||||
val date: String = "",
|
||||
val pomodoros: Int = 0,
|
||||
@SerialName("focus_minutes") val focusMinutes: Int = 0,
|
||||
@SerialName("goal_met") val goalMet: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatsSummaryDto(
|
||||
val today: DayMetricsDto = DayMetricsDto(),
|
||||
val streak: StreakDto = StreakDto(),
|
||||
val lifetime: LifetimeDto = LifetimeDto(),
|
||||
@SerialName("average_7d") val average7d: AverageDto = AverageDto(),
|
||||
val range: List<RangeDayDto> = emptyList(),
|
||||
val heatmap: List<RangeDayDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthMeDto(
|
||||
val authenticated: Boolean = false,
|
||||
@SerialName("login_required") val loginRequired: Boolean = false,
|
||||
val username: String = "",
|
||||
@SerialName("password_login_enabled") val passwordLoginEnabled: Boolean = false,
|
||||
@SerialName("telegram_login_enabled") val telegramLoginEnabled: Boolean = false,
|
||||
@SerialName("telegram_bot_username") val telegramBotUsername: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HealthDto(
|
||||
val status: String = "",
|
||||
val version: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionsResponseDto(
|
||||
val date: String = "",
|
||||
val sessions: List<DaySessionDto> = emptyList(),
|
||||
val metrics: DayMetricsDto = DayMetricsDto(),
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Error shapes, and what a call can come back as.
|
||||
*
|
||||
* FastAPI returns `{"detail": "..."}` for an `HTTPException` and
|
||||
* `{"detail": [{loc, msg, ...}]}` for a 422. Both existing clients unwrap both forms —
|
||||
* `frontend/src/lib/api.ts` and `desktop/kadence_desktop/api.py` — so this does too,
|
||||
* and renders a validation error as `field: message` rather than raw JSON.
|
||||
*/
|
||||
package tech.kadence.android.data.net
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/** The outcome of one call. */
|
||||
sealed interface ApiResult<out T> {
|
||||
data class Ok<T>(val value: T) : ApiResult<T>
|
||||
|
||||
/** A 4xx/5xx the server explained. Never retried blindly. */
|
||||
data class HttpError(val status: Int, val detail: String) : ApiResult<Nothing>
|
||||
|
||||
/** The session or token is no longer good; the UI drops to the login gate. */
|
||||
data class Unauthorized(val detail: String) : ApiResult<Nothing>
|
||||
|
||||
/**
|
||||
* Nothing came back. **Ambiguous for a write**: the request may or may not have
|
||||
* been applied, which is why the outbox probes a post-condition instead of
|
||||
* resending. See `Outbox.kt` in `:core`.
|
||||
*/
|
||||
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
|
||||
}
|
||||
|
||||
/** Pull a human message out of either `detail` shape; fall back to the status line. */
|
||||
fun parseErrorDetail(body: String?, fallback: String): String {
|
||||
if (body.isNullOrBlank()) return fallback
|
||||
val element = runCatching { KadenceJson.parseToJsonElement(body) }.getOrNull() ?: return fallback
|
||||
val detail = (element as? JsonObject)?.get("detail") ?: return fallback
|
||||
|
||||
// The simple case: {"detail": "No interval is running."}
|
||||
(detail as? JsonPrimitive)?.contentOrNull?.let { return it }
|
||||
|
||||
// The 422 case: {"detail": [{"loc": ["body", "minutes"], "msg": "..."}]}
|
||||
if (detail is JsonArray) {
|
||||
val parts = detail.jsonArray.mapNotNull { item ->
|
||||
val obj = item as? JsonObject ?: return@mapNotNull null
|
||||
val loc = (obj["loc"] as? JsonArray)
|
||||
?.drop(1)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?.joinToString(".")
|
||||
.orEmpty()
|
||||
val msg = (obj["msg"] as? JsonPrimitive)?.contentOrNull.orEmpty()
|
||||
listOf(loc, msg).filter { it.isNotBlank() }.joinToString(": ").ifBlank { null }
|
||||
}
|
||||
if (parts.isNotEmpty()) return parts.joinToString("; ")
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The one JSON configuration.
|
||||
*
|
||||
* This object *is* the tolerance strategy for a backend with no response models:
|
||||
*
|
||||
* * `ignoreUnknownKeys` — the server may grow a field at any time.
|
||||
* * `coerceInputValues` — a declared-non-null field arriving as `null` falls back to
|
||||
* its default instead of throwing.
|
||||
* * `explicitNulls = false` — we never send `null` for an absent value, which matters
|
||||
* for the settings PUT: the backend deep-merges, so an explicit null would clobber.
|
||||
* * `isLenient` — survives the odd unquoted value.
|
||||
*/
|
||||
package tech.kadence.android.data.net
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
val KadenceJson: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
explicitNulls = false
|
||||
isLenient = true
|
||||
encodeDefaults = false
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* The one place that talks to the backend.
|
||||
*
|
||||
* Hand-written over OkHttp rather than generated, for two reasons specific to this
|
||||
* server: the base URL is configured by the user at runtime (so a client that freezes
|
||||
* it at construction is the wrong shape), and there are no response models to generate
|
||||
* from anyway. `desktop/kadence_desktop/api.py` does the same job in 150 stdlib lines;
|
||||
* keeping this file shaped like it means the two clients stay diff-able.
|
||||
*
|
||||
* Auth mirrors the other clients: `X-API-Token` when a token is configured, plus a
|
||||
* cookie jar for the `kadence_session` the browser login sets. Either is sufficient —
|
||||
* `backend/app/api/deps.py` accepts either.
|
||||
*/
|
||||
package tech.kadence.android.data.net
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
/** Where the backend is and how we prove who we are. Both user-supplied at runtime. */
|
||||
interface AuthConfig {
|
||||
val baseUrl: String
|
||||
val apiToken: String?
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds `kadence_session` across calls and process restarts.
|
||||
*
|
||||
* In-memory here; the real implementation persists through the Keystore-wrapped store.
|
||||
* Kept behind an interface so the HTTP tests can use a plain map.
|
||||
*/
|
||||
class SimpleCookieJar : CookieJar {
|
||||
private val cookies = mutableMapOf<String, MutableList<Cookie>>()
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val host = url.host
|
||||
val existing = this.cookies.getOrPut(host) { mutableListOf() }
|
||||
for (cookie in cookies) {
|
||||
existing.removeAll { it.name == cookie.name }
|
||||
existing.add(cookie)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> =
|
||||
cookies[url.host]?.filter { it.expiresAt > System.currentTimeMillis() } ?: emptyList()
|
||||
|
||||
fun clear() = cookies.clear()
|
||||
|
||||
fun named(name: String): Cookie? = cookies.values.flatten().firstOrNull { it.name == name }
|
||||
}
|
||||
|
||||
/** Adds the shared-secret header the desktop client also sends. */
|
||||
class AuthInterceptor(private val config: () -> AuthConfig) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
|
||||
val builder = chain.request().newBuilder()
|
||||
.header("Accept", "application/json")
|
||||
config().apiToken?.takeIf { it.isNotBlank() }?.let {
|
||||
builder.header("X-API-Token", it)
|
||||
}
|
||||
return chain.proceed(builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A 401 on anything but an `/api/auth/` path means the session died mid-use.
|
||||
*
|
||||
* The web app does the same (`api.ts`'s `onUnauthorized`) so the UI can drop to the
|
||||
* login screen instead of showing a generic "backend unreachable".
|
||||
*/
|
||||
class UnauthorizedBus {
|
||||
private val listeners = mutableSetOf<() -> Unit>()
|
||||
|
||||
fun subscribe(listener: () -> Unit): () -> Unit {
|
||||
listeners += listener
|
||||
return { listeners -= listener }
|
||||
}
|
||||
|
||||
fun publish() = listeners.toList().forEach { it() }
|
||||
}
|
||||
|
||||
class KadenceApi(
|
||||
private val config: () -> AuthConfig,
|
||||
val cookieJar: SimpleCookieJar = SimpleCookieJar(),
|
||||
val unauthorized: UnauthorizedBus = UnauthorizedBus(),
|
||||
client: OkHttpClient? = null,
|
||||
) {
|
||||
private val http: OkHttpClient = client ?: OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(false)
|
||||
.cookieJar(cookieJar)
|
||||
.addInterceptor(AuthInterceptor(config))
|
||||
.build()
|
||||
|
||||
/** Raw request; every typed call below funnels through here. */
|
||||
suspend fun request(
|
||||
method: String,
|
||||
path: String,
|
||||
body: String? = null,
|
||||
): ApiResult<String> = withContext(Dispatchers.IO) {
|
||||
val base = config().baseUrl.trimEnd('/')
|
||||
val url = "$base$path".toHttpUrlOrNull()
|
||||
?: return@withContext ApiResult.NetworkError(
|
||||
IOException("not a usable backend URL: $base"),
|
||||
)
|
||||
|
||||
val requestBody = when {
|
||||
body != null -> body.toRequestBody(JSON_MEDIA)
|
||||
method == "POST" || method == "PUT" || method == "PATCH" ->
|
||||
"{}".toRequestBody(JSON_MEDIA)
|
||||
else -> null
|
||||
}
|
||||
|
||||
val request = Request.Builder().url(url).method(method, requestBody).build()
|
||||
|
||||
try {
|
||||
http.newCall(request).execute().use { response ->
|
||||
val text = response.body?.string()
|
||||
if (response.code == 401 && !path.startsWith("/api/auth/")) {
|
||||
unauthorized.publish()
|
||||
return@withContext ApiResult.Unauthorized(
|
||||
parseErrorDetail(text, "not authorised"),
|
||||
)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext ApiResult.HttpError(
|
||||
response.code,
|
||||
parseErrorDetail(text, "${response.code} ${response.message}"),
|
||||
)
|
||||
}
|
||||
ApiResult.Ok(text.orEmpty())
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
// Deliberately not retried here: for a write this is ambiguous, and the
|
||||
// outbox resolves it with a post-condition probe.
|
||||
ApiResult.NetworkError(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> get(path: String): ApiResult<T> = decode(request("GET", path))
|
||||
|
||||
suspend inline fun <reified T> post(path: String, body: String? = null): ApiResult<T> =
|
||||
decode(request("POST", path, body))
|
||||
|
||||
suspend inline fun <reified T> put(path: String, body: String): ApiResult<T> =
|
||||
decode(request("PUT", path, body))
|
||||
|
||||
suspend inline fun <reified T> patch(path: String, body: String): ApiResult<T> =
|
||||
decode(request("PATCH", path, body))
|
||||
|
||||
suspend inline fun <reified T> delete(path: String): ApiResult<T> =
|
||||
decode(request("DELETE", path))
|
||||
|
||||
/** Decode a successful body, turning a malformed payload into an HttpError. */
|
||||
inline fun <reified T> decode(result: ApiResult<String>): ApiResult<T> = when (result) {
|
||||
is ApiResult.Ok -> runCatching {
|
||||
ApiResult.Ok(KadenceJson.decodeFromString<T>(result.value))
|
||||
}.getOrElse { ApiResult.HttpError(200, "unreadable response: ${it.message}") }
|
||||
is ApiResult.HttpError -> result
|
||||
is ApiResult.Unauthorized -> result
|
||||
is ApiResult.NetworkError -> result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* The shared page furniture, ported from `frontend/src/components/Layout.tsx` and the
|
||||
* QML `components/` set so all clients read as one product.
|
||||
*
|
||||
* The rule throughout: a hairline border does the separating. No shadows, no tonal
|
||||
* elevation, no cards floating over the paper.
|
||||
*/
|
||||
package tech.kadence.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import tech.kadence.android.ui.theme.KadenceTheme
|
||||
|
||||
/** One hairline, defined once so no call site hand-rolls a 1px box. */
|
||||
@Composable
|
||||
fun HorizontalRule(modifier: Modifier = Modifier, strong: Boolean = false) {
|
||||
val colors = KadenceTheme.colors
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(if (strong) colors.ruleStrong else colors.rule),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerticalRule(modifier: Modifier = Modifier, height: Dp) {
|
||||
Box(modifier.width(1.dp).height(height).background(KadenceTheme.colors.rule))
|
||||
}
|
||||
|
||||
/** The small-caps letterspaced label that sits above a serif title. */
|
||||
@Composable
|
||||
fun Eyebrow(text: String, modifier: Modifier = Modifier, color: Color? = null) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = KadenceTheme.type.eyebrow,
|
||||
color = color ?: KadenceTheme.colors.inkMuted,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** Eyebrow, serif title, optional caption, then a rule. */
|
||||
@Composable
|
||||
fun PageHeader(
|
||||
eyebrow: String,
|
||||
title: String,
|
||||
caption: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
actions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
Eyebrow(eyebrow)
|
||||
Column(Modifier.padding(top = 2.dp)) {
|
||||
Text(text = title, style = KadenceTheme.type.pageTitle, color = colors.ink)
|
||||
}
|
||||
if (caption != null) {
|
||||
Text(
|
||||
text = caption,
|
||||
style = KadenceTheme.type.bodySmall,
|
||||
color = colors.inkMuted,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
if (actions != null) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) { actions() }
|
||||
}
|
||||
HorizontalRule(Modifier.padding(top = 14.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** An eyebrow, a rule filling the rest of the row, and an optional trailing note. */
|
||||
@Composable
|
||||
fun SectionTitle(
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
trailing: String? = null,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
Row(
|
||||
modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Eyebrow(label)
|
||||
HorizontalRule(Modifier.weight(1f))
|
||||
if (trailing != null) {
|
||||
Text(text = trailing, style = KadenceTheme.type.caption, color = colors.inkFaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A bordered surface. No shadow, no tint — the border does the separating. */
|
||||
@Composable
|
||||
fun Panel(
|
||||
modifier: Modifier = Modifier,
|
||||
padding: Dp = 16.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = colors.surface,
|
||||
border = BorderStroke(1.dp, colors.rule),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Column(Modifier.padding(padding)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
enum class StatSize { Small, Medium, Large }
|
||||
|
||||
/** Label, serif figure, caption — the web app's `Stat`. */
|
||||
@Composable
|
||||
fun Stat(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
caption: String? = null,
|
||||
size: StatSize = StatSize.Medium,
|
||||
accent: Color? = null,
|
||||
tabular: Boolean = false,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
val style = when (size) {
|
||||
StatSize.Small -> KadenceTheme.type.statSmall
|
||||
StatSize.Medium -> KadenceTheme.type.statMedium
|
||||
StatSize.Large -> KadenceTheme.type.statLarge
|
||||
}
|
||||
Column(modifier) {
|
||||
Eyebrow(label)
|
||||
if (tabular) {
|
||||
TabularFigures(
|
||||
text = value,
|
||||
style = style,
|
||||
color = accent ?: colors.ink,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = value,
|
||||
style = style,
|
||||
color = accent ?: colors.ink,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
if (caption != null) {
|
||||
Text(
|
||||
text = caption,
|
||||
style = KadenceTheme.type.caption,
|
||||
color = colors.inkFaint,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The filled accent button. Exactly one per view. */
|
||||
@Composable
|
||||
fun PrimaryButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = 40.dp),
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colors.accent,
|
||||
contentColor = colors.onAccent,
|
||||
disabledContainerColor = colors.rule,
|
||||
disabledContentColor = colors.inkFaint,
|
||||
),
|
||||
elevation = null,
|
||||
) {
|
||||
Text(text = text, style = KadenceTheme.type.button)
|
||||
}
|
||||
}
|
||||
|
||||
/** The outlined one. Everything that is not the single primary action. */
|
||||
@Composable
|
||||
fun GhostButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = 40.dp),
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(1.dp, colors.ruleStrong),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = colors.ink,
|
||||
disabledContentColor = colors.inkFaint,
|
||||
),
|
||||
) {
|
||||
Text(text = text, style = KadenceTheme.type.button)
|
||||
}
|
||||
}
|
||||
|
||||
/** The pill used for timer presets. */
|
||||
@Composable
|
||||
fun PresetChip(
|
||||
text: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
Surface(
|
||||
modifier = modifier.heightIn(min = 32.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (selected) colors.accent else Color.Transparent,
|
||||
border = BorderStroke(1.dp, if (selected) colors.accent else colors.rule),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Box(
|
||||
Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = KadenceTheme.type.button,
|
||||
color = if (selected) colors.onAccent else colors.ink,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* A serif number that does not twitch.
|
||||
*
|
||||
* Fraunces has no `tnum` feature and its digits are proportional — "1" is 1024 units
|
||||
* where "0" is 1461 — so a clock redrawn every second would visibly jump as the digits
|
||||
* change. This lays each character out in a box as wide as the widest digit in the
|
||||
* font at that style, which is the only way to get tabular behaviour out of a font that
|
||||
* does not provide it.
|
||||
*
|
||||
* Only worth using for numbers that change in place: the dial clock, the Live Update
|
||||
* text, a stat figure that ticks. Static figures can use a plain `Text`.
|
||||
*/
|
||||
package tech.kadence.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
||||
@Composable
|
||||
fun TabularFigures(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
|
||||
// The widest digit at this exact style, measured once per style change.
|
||||
val digitWidth: Dp = remember(style, density) {
|
||||
val widest = (0..9).maxOf { d ->
|
||||
measurer.measure(AnnotatedString(d.toString()), style = style).size.width
|
||||
}
|
||||
with(density) { widest.toDp() }
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier.clearAndSetSemantics { },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
for (ch in text) {
|
||||
if (ch.isDigit()) {
|
||||
Text(
|
||||
text = ch.toString(),
|
||||
style = style.copy(textAlign = TextAlign.Center),
|
||||
color = color,
|
||||
modifier = Modifier.width(digitWidth),
|
||||
)
|
||||
} else {
|
||||
// Separators keep their natural width; only digits need a fixed box.
|
||||
Text(text = ch.toString(), style = style, color = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* The dial.
|
||||
*
|
||||
* Same geometry as `frontend/src/components/TimerDial.tsx` and
|
||||
* `desktop/.../qml/components/TimerDial.qml`, with the tick arithmetic coming from
|
||||
* `:core` (`dialTickCount` / `dialSpentTicks`) so the dial, the Glyph ring and the
|
||||
* tests can never disagree about what one tick is worth.
|
||||
*
|
||||
* `remaining` is passed as a lambda on purpose: the engine emits a value every second,
|
||||
* and reading it inside the draw scope keeps the 1 Hz tick in the draw phase instead of
|
||||
* recomposing the whole dial sixty times a minute.
|
||||
*/
|
||||
package tech.kadence.android.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import tech.kadence.android.ui.theme.KadenceTheme
|
||||
import tech.kadence.core.clock
|
||||
import tech.kadence.core.dialSpentTicks
|
||||
import tech.kadence.core.dialTickCount
|
||||
import tech.kadence.core.progressOf
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
fun TimerDial(
|
||||
label: String,
|
||||
remaining: () -> Int,
|
||||
plannedSeconds: Int,
|
||||
accent: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
sublabel: String? = null,
|
||||
paused: Boolean = false,
|
||||
idle: Boolean = false,
|
||||
) {
|
||||
val colors = KadenceTheme.colors
|
||||
val remainingNow = remaining()
|
||||
val elapsed = progressOf(remainingNow, plannedSeconds)
|
||||
|
||||
// Glide the arc over the second rather than stepping it, matching the web app's
|
||||
// `transition: stroke-dashoffset 1s linear`.
|
||||
val animatedElapsed by animateFloatAsState(
|
||||
targetValue = elapsed,
|
||||
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
|
||||
label = "dialElapsed",
|
||||
)
|
||||
|
||||
val clockAlpha by animateFloatAsState(
|
||||
targetValue = if (paused) 0.55f else 1f,
|
||||
animationSpec = tween(durationMillis = 180),
|
||||
label = "clockAlpha",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.semantics {
|
||||
contentDescription = "$label: ${clock(remainingNow)} remaining"
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
Canvas(Modifier.fillMaxWidth().aspectRatio(1f)) {
|
||||
val side = minOf(size.width, size.height)
|
||||
// Keep the web app's 26:300 inset ratio so the dial scales rather than
|
||||
// assuming a 300px box.
|
||||
val inset = side * (26f / 300f)
|
||||
val radius = side / 2f - inset
|
||||
val centre = Offset(size.width / 2f, size.height / 2f)
|
||||
|
||||
val count = dialTickCount(plannedSeconds)
|
||||
val spent = dialSpentTicks(plannedSeconds, remainingNow)
|
||||
|
||||
// The base ring.
|
||||
drawCircle(
|
||||
color = colors.rule,
|
||||
radius = radius,
|
||||
center = centre,
|
||||
style = Stroke(width = with(density) { 1.5.dp.toPx() }),
|
||||
)
|
||||
|
||||
// One tick per minute, every fifth one major, spent ticks tinted.
|
||||
val tickOuter = radius - side * (7f / 300f)
|
||||
for (i in 0 until count) {
|
||||
val major = i % 5 == 0
|
||||
val lengthPx = side * (if (major) 9f else 5f) / 300f
|
||||
val angle = i.toFloat() / count * 2f * PI.toFloat() - PI.toFloat() / 2f
|
||||
val cosA = cos(angle)
|
||||
val sinA = sin(angle)
|
||||
val inner = tickOuter - lengthPx
|
||||
val isSpent = i < spent && !idle
|
||||
drawLine(
|
||||
color = if (isSpent) accent else colors.rule,
|
||||
start = Offset(centre.x + tickOuter * cosA, centre.y + tickOuter * sinA),
|
||||
end = Offset(centre.x + inner * cosA, centre.y + inner * sinA),
|
||||
strokeWidth = with(density) { (if (major) 1.4.dp else 1.dp).toPx() },
|
||||
cap = StrokeCap.Round,
|
||||
alpha = if (idle) 0.5f else 1f,
|
||||
)
|
||||
}
|
||||
|
||||
// The remaining arc, draining clockwise from twelve.
|
||||
if (!idle) {
|
||||
val stroke = with(density) { 2.5.dp.toPx() }
|
||||
drawArc(
|
||||
color = accent,
|
||||
startAngle = -90f,
|
||||
sweepAngle = (1f - animatedElapsed) * 360f,
|
||||
useCenter = false,
|
||||
topLeft = Offset(centre.x - radius, centre.y - radius),
|
||||
size = Size(radius * 2, radius * 2),
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||
alpha = if (paused) 0.4f else 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth(0.7f),
|
||||
) {
|
||||
Eyebrow(label, color = if (idle) colors.inkMuted else accent)
|
||||
// Fraunces has no tabular figures, so the clock gets fixed-advance boxes.
|
||||
TabularFigures(
|
||||
text = clock(remainingNow),
|
||||
style = KadenceTheme.type.clock,
|
||||
color = colors.ink.copy(alpha = clockAlpha),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
if (sublabel != null) {
|
||||
Text(
|
||||
text = sublabel,
|
||||
style = KadenceTheme.type.caption,
|
||||
color = colors.inkMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The theme.
|
||||
*
|
||||
* Colour values are not defined here — they live in `:core` as ARGB longs, checked
|
||||
* against `frontend/src/tokens.ts` by `TokenParityTest`. This file only wraps them in
|
||||
* Compose types and decides the accent.
|
||||
*
|
||||
* The palette is semantic (paper / ink / rule / ember), not Material roles, so it rides
|
||||
* on its own CompositionLocals. A minimal `ColorScheme` is derived from it purely so
|
||||
* that `Switch`, `TextField` and the rest of M3 behave like Android components instead
|
||||
* of being restyled from scratch.
|
||||
*/
|
||||
package tech.kadence.android.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import tech.kadence.core.KadenceTokens
|
||||
import tech.kadence.core.VizTokens
|
||||
import tech.kadence.core.accentArgb
|
||||
import tech.kadence.core.kadenceTokens
|
||||
import tech.kadence.core.onAccentArgb
|
||||
import tech.kadence.core.vizTokens
|
||||
|
||||
/** An `0xAARRGGBB` long from `:core` as a Compose colour. */
|
||||
private fun Long.toColor(): Color = Color(this.toInt())
|
||||
|
||||
/** The semantic palette, as Compose colours. */
|
||||
@Immutable
|
||||
data class KadenceColors(
|
||||
val paper: Color,
|
||||
val surface: Color,
|
||||
val surfaceSunken: Color,
|
||||
val ink: Color,
|
||||
val inkMuted: Color,
|
||||
val inkFaint: Color,
|
||||
val rule: Color,
|
||||
val ruleStrong: Color,
|
||||
val accent: Color,
|
||||
val onAccent: Color,
|
||||
val accentSoft: Color,
|
||||
val pine: Color,
|
||||
val amber: Color,
|
||||
val clay: Color,
|
||||
)
|
||||
|
||||
/** The chart palette. Separate on purpose — see `VizTokens` in `:core`. */
|
||||
@Immutable
|
||||
data class KadenceViz(
|
||||
val work: Color,
|
||||
val shortBreak: Color,
|
||||
val longBreak: Color,
|
||||
val sequential: List<Color>,
|
||||
val grid: Color,
|
||||
val axis: Color,
|
||||
val empty: Color,
|
||||
)
|
||||
|
||||
private fun colorsFrom(tokens: KadenceTokens, accent: Long, accentSoft: Long): KadenceColors =
|
||||
KadenceColors(
|
||||
paper = tokens.paper.toColor(),
|
||||
surface = tokens.surface.toColor(),
|
||||
surfaceSunken = tokens.surfaceSunken.toColor(),
|
||||
ink = tokens.ink.toColor(),
|
||||
inkMuted = tokens.inkMuted.toColor(),
|
||||
inkFaint = tokens.inkFaint.toColor(),
|
||||
rule = tokens.rule.toColor(),
|
||||
ruleStrong = tokens.ruleStrong.toColor(),
|
||||
accent = accent.toColor(),
|
||||
onAccent = onAccentArgb(accent).toColor(),
|
||||
accentSoft = accentSoft.toColor(),
|
||||
pine = tokens.pine.toColor(),
|
||||
amber = tokens.amber.toColor(),
|
||||
clay = tokens.clay.toColor(),
|
||||
)
|
||||
|
||||
private fun vizFrom(tokens: VizTokens): KadenceViz = KadenceViz(
|
||||
work = tokens.work.toColor(),
|
||||
shortBreak = tokens.shortBreak.toColor(),
|
||||
longBreak = tokens.longBreak.toColor(),
|
||||
sequential = tokens.sequential.map { it.toColor() },
|
||||
grid = tokens.grid.toColor(),
|
||||
axis = tokens.axis.toColor(),
|
||||
empty = tokens.empty.toColor(),
|
||||
)
|
||||
|
||||
val LocalKadenceColors = staticCompositionLocalOf {
|
||||
colorsFrom(kadenceTokens(false), kadenceTokens(false).ember, kadenceTokens(false).emberSoft)
|
||||
}
|
||||
val LocalKadenceViz = staticCompositionLocalOf { vizFrom(vizTokens(false)) }
|
||||
val LocalKadenceTypography = staticCompositionLocalOf { kadenceTypography() }
|
||||
|
||||
/** Reads as `KadenceTheme.colors.ink` at a call site. */
|
||||
object KadenceTheme {
|
||||
val colors: KadenceColors
|
||||
@Composable get() = LocalKadenceColors.current
|
||||
val viz: KadenceViz
|
||||
@Composable get() = LocalKadenceViz.current
|
||||
val type: KadenceTypography
|
||||
@Composable get() = LocalKadenceTypography.current
|
||||
}
|
||||
|
||||
/**
|
||||
* @param dynamicAccent take the accent from the system's wallpaper-derived palette.
|
||||
* The canvas stays Kadence's either way; only the single accent changes. See
|
||||
* `accentArgb` / `onAccentArgb` in `:core` for the policy and its contrast guard.
|
||||
*/
|
||||
@Composable
|
||||
fun KadenceTheme(
|
||||
dark: Boolean = isSystemInDarkTheme(),
|
||||
dynamicAccent: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val tokens = kadenceTokens(dark)
|
||||
|
||||
val systemAccent: Long? = if (dynamicAccent && Build.VERSION.SDK_INT >= 31) {
|
||||
val scheme = if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
scheme.primary.toArgb().toLong() and 0xFFFFFFFFL
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val accent = accentArgb(systemAccent, dark)
|
||||
// The soft wash keeps the accent's hue; derive it rather than reusing ember's.
|
||||
val accentSoft = (accent and 0x00FFFFFFL) or (if (dark) 0x24000000L else 0x1A000000L)
|
||||
|
||||
val colors = colorsFrom(tokens, accent, accentSoft)
|
||||
val viz = vizFrom(vizTokens(dark))
|
||||
|
||||
// M3 exists only to make stock components behave; nothing reads these roles for
|
||||
// layout, and tonal elevation is never used.
|
||||
val base = if (dark) darkColorScheme() else lightColorScheme()
|
||||
val scheme = base.copy(
|
||||
primary = colors.accent,
|
||||
onPrimary = colors.onAccent,
|
||||
secondary = colors.pine,
|
||||
tertiary = colors.amber,
|
||||
error = colors.clay,
|
||||
background = colors.paper,
|
||||
onBackground = colors.ink,
|
||||
surface = colors.surface,
|
||||
onSurface = colors.ink,
|
||||
surfaceVariant = colors.surfaceSunken,
|
||||
onSurfaceVariant = colors.inkMuted,
|
||||
outline = colors.ruleStrong,
|
||||
outlineVariant = colors.rule,
|
||||
)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalKadenceColors provides colors,
|
||||
LocalKadenceViz provides viz,
|
||||
LocalKadenceTypography provides kadenceTypography(),
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = scheme,
|
||||
shapes = Shapes(
|
||||
extraSmall = androidx.compose.foundation.shape.RoundedCornerShape(6.dp),
|
||||
small = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
|
||||
medium = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||
large = androidx.compose.foundation.shape.RoundedCornerShape(14.dp),
|
||||
),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Type.
|
||||
*
|
||||
* Fraunces for figures and titles, Inter for everything else — the same pairing as the
|
||||
* web app and the desktop app, bundled as variable fonts (both SIL OFL; the licences
|
||||
* ship in `res/raw`).
|
||||
*
|
||||
* **The tabular-figures problem.** The web app sets `font-variant-numeric: tabular-nums`
|
||||
* globally, but Fraunces has no `tnum` feature *and* its digits are proportional — a
|
||||
* "1" is 1024 units against a "0" at 1461, a 30% difference. So that CSS does nothing
|
||||
* for the serif clock, and a 1 Hz countdown in Fraunces visibly jiggles. Inter does ship
|
||||
* `tnum`, so the sans numbers are fixed with a font feature; the serif ones are fixed by
|
||||
* [tech.kadence.android.ui.components.TabularFigures], which lays every glyph in a box
|
||||
* the width of the widest digit.
|
||||
*/
|
||||
package tech.kadence.android.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import tech.kadence.android.R
|
||||
|
||||
private fun fraunces(weight: Int, opsz: Float) = Font(
|
||||
resId = R.font.fraunces,
|
||||
weight = FontWeight(weight),
|
||||
variationSettings = FontVariation.Settings(
|
||||
FontVariation.weight(weight),
|
||||
FontVariation.opticalSizing(opsz.sp),
|
||||
),
|
||||
)
|
||||
|
||||
private fun inter(weight: Int) = Font(
|
||||
resId = R.font.inter,
|
||||
weight = FontWeight(weight),
|
||||
variationSettings = FontVariation.Settings(FontVariation.weight(weight)),
|
||||
)
|
||||
|
||||
/** Display serif. Optical sizing is set per weight, as the web app's `opsz` axis does. */
|
||||
val Fraunces = FontFamily(
|
||||
fraunces(400, 72f),
|
||||
fraunces(500, 72f),
|
||||
fraunces(600, 72f),
|
||||
)
|
||||
|
||||
val Inter = FontFamily(
|
||||
inter(400),
|
||||
inter(500),
|
||||
inter(600),
|
||||
)
|
||||
|
||||
/** Inter ships `tnum`; this is what keeps the sans numbers from twitching. */
|
||||
private const val TABULAR = "tnum"
|
||||
|
||||
@Immutable
|
||||
data class KadenceTypography(
|
||||
/** Small-caps-ish eyebrow above a title. Uppercase at the call site, not here. */
|
||||
val eyebrow: TextStyle,
|
||||
val pageTitle: TextStyle,
|
||||
val statSmall: TextStyle,
|
||||
val statMedium: TextStyle,
|
||||
val statLarge: TextStyle,
|
||||
/** The dial clock; its size is supplied by the dial (~0.21 x its width). */
|
||||
val clock: TextStyle,
|
||||
val body: TextStyle,
|
||||
val bodySmall: TextStyle,
|
||||
val caption: TextStyle,
|
||||
val button: TextStyle,
|
||||
val mono: TextStyle,
|
||||
)
|
||||
|
||||
fun kadenceTypography(): KadenceTypography = KadenceTypography(
|
||||
eyebrow = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 10.5.sp,
|
||||
letterSpacing = 0.12.em,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
pageTitle = TextStyle(
|
||||
fontFamily = Fraunces,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 27.sp,
|
||||
letterSpacing = (-0.02).em,
|
||||
lineHeight = 32.sp,
|
||||
),
|
||||
statSmall = TextStyle(
|
||||
fontFamily = Fraunces,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 24.sp,
|
||||
letterSpacing = (-0.02).em,
|
||||
lineHeight = 27.sp,
|
||||
),
|
||||
statMedium = TextStyle(
|
||||
fontFamily = Fraunces,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 30.sp,
|
||||
letterSpacing = (-0.02).em,
|
||||
lineHeight = 33.sp,
|
||||
),
|
||||
statLarge = TextStyle(
|
||||
fontFamily = Fraunces,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 38.sp,
|
||||
letterSpacing = (-0.02).em,
|
||||
lineHeight = 42.sp,
|
||||
),
|
||||
clock = TextStyle(
|
||||
fontFamily = Fraunces,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 56.sp,
|
||||
letterSpacing = (-0.03).em,
|
||||
lineHeight = 60.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
body = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 23.sp,
|
||||
fontFeatureSettings = TABULAR,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 13.5.sp,
|
||||
lineHeight = 21.sp,
|
||||
fontFeatureSettings = TABULAR,
|
||||
),
|
||||
caption = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 18.sp,
|
||||
fontFeatureSettings = TABULAR,
|
||||
),
|
||||
button = TextStyle(
|
||||
fontFamily = Inter,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
letterSpacing = 0.em,
|
||||
),
|
||||
mono = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The tomato and leaf from desktop/packaging/kadence.svg, in the adaptive-icon
|
||||
safe zone. The white arc is the timer ring, draining clockwise from the top. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<path
|
||||
android:fillColor="#E8502F"
|
||||
android:pathData="M54,60 m-25,0 a25,25 0 1,0 50,0 a25,25 0 1,0 -50,0" />
|
||||
|
||||
<path
|
||||
android:pathData="M54,43 A17,17 0 0 1 71,60"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5"
|
||||
android:strokeLineCap="round" />
|
||||
|
||||
<path
|
||||
android:fillColor="#3E8E41"
|
||||
android:pathData="M54,35 C46,33 42,27 43,22 C50,22 55,27 54,35 Z" />
|
||||
|
||||
<path
|
||||
android:strokeColor="#2E7D32"
|
||||
android:strokeWidth="3"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M54,35 L54,27" />
|
||||
</vector>
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/icon_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- tokens.ts `dark.paper`. -->
|
||||
<color name="paper">#121110</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Mirrors tokens.ts `light.paper`; only used for the pre-Compose window frame. -->
|
||||
<color name="paper">#FAF7F2</color>
|
||||
<!-- The launcher icon's ground: paper, so the icon reads as the same product. -->
|
||||
<color name="icon_background">#FAF7F2</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Kadence</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
A bare platform theme: the paper ground and every colour come from Compose, not
|
||||
from Android resources, so there is exactly one source of truth for the palette.
|
||||
windowBackground is set so the cold-start frame is paper rather than white.
|
||||
-->
|
||||
<style name="Theme.Kadence" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/paper</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,256 @@
|
||||
package tech.kadence.android
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Test
|
||||
import tech.kadence.android.data.dto.AuthMeDto
|
||||
import tech.kadence.android.data.dto.BlockEventsDto
|
||||
import tech.kadence.android.data.dto.BlockerPolicyDto
|
||||
import tech.kadence.android.data.dto.DailyReportEnvelopeDto
|
||||
import tech.kadence.android.data.dto.StatsSummaryDto
|
||||
import tech.kadence.android.data.dto.TaskListDto
|
||||
import tech.kadence.android.data.dto.TimerStateDto
|
||||
import tech.kadence.android.data.net.KadenceJson
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The contract test, against committed mock data.
|
||||
*
|
||||
* The backend declares no response models, so there is nothing to generate DTOs from
|
||||
* and nothing to validate them against — the only defence is fixtures plus deliberate
|
||||
* corruption. Each DTO is decoded from a realistic fixture, then from the same fixture
|
||||
* with every optional key stripped, with unknown keys added, and with nulls where a
|
||||
* value is declared. None of that may throw, because the alternative is a phone that
|
||||
* crashes when the server grows a field.
|
||||
*
|
||||
* These tests never touch a network or a running backend.
|
||||
*/
|
||||
class DtoToleranceTest {
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) {
|
||||
"missing fixture: $name"
|
||||
}.bufferedReader().readText()
|
||||
|
||||
private inline fun <reified T> decode(name: String): T =
|
||||
KadenceJson.decodeFromString<T>(fixture(name))
|
||||
|
||||
/** The same payload with three keys the client has never heard of. */
|
||||
private fun withUnknownKeys(raw: String): String {
|
||||
val obj = KadenceJson.parseToJsonElement(raw).jsonObject
|
||||
val grown = buildJsonObject {
|
||||
obj.forEach { (k, v) -> put(k, v) }
|
||||
put("some_new_field", JsonPrimitive("surprise"))
|
||||
put("another_one", JsonPrimitive(42))
|
||||
put("nested_novelty", buildJsonObject { put("x", JsonPrimitive(1)) })
|
||||
}
|
||||
return grown.toString()
|
||||
}
|
||||
|
||||
/** Every top-level value replaced with null, as a sloppy serialiser might. */
|
||||
private fun allNulls(raw: String): String {
|
||||
val obj = KadenceJson.parseToJsonElement(raw).jsonObject
|
||||
val nulled = buildJsonObject {
|
||||
obj.keys.forEach { k -> put(k, kotlinx.serialization.json.JsonNull) }
|
||||
}
|
||||
return nulled.toString()
|
||||
}
|
||||
|
||||
// --- the happy path, asserting the values that matter ---------------------
|
||||
|
||||
@Test
|
||||
fun `a running interval decodes`() {
|
||||
val state = decode<TimerStateDto>("timer_state_running.json")
|
||||
val current = assertNotNull(state.current)
|
||||
assertEquals(412L, current.id)
|
||||
assertEquals("work", current.kind)
|
||||
assertEquals(1104, current.remainingSeconds)
|
||||
assertEquals(1500, current.plannedSeconds)
|
||||
assertEquals(1, current.interruptions)
|
||||
assertEquals("PLAT-184", current.task?.externalId)
|
||||
assertEquals(6, state.completedToday)
|
||||
assertEquals("short_break", state.nextKind)
|
||||
assertTrue(state.timerEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `durations are minutes, not seconds`() {
|
||||
// The trap: `pomodoro.state()` sends minutes. Treating them as seconds would
|
||||
// make every interval 25 seconds long.
|
||||
val state = decode<TimerStateDto>("timer_state_running.json")
|
||||
assertEquals(25, state.durations["work"])
|
||||
assertEquals(5, state.durations["short_break"])
|
||||
assertEquals(15, state.durations["long_break"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an idle state has no current interval`() {
|
||||
val state = decode<TimerStateDto>("timer_state_idle.json")
|
||||
assertNull(state.current)
|
||||
assertEquals("work", state.nextKind)
|
||||
assertEquals(45, state.durations["work"], "the Classic preset")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a complete reply carries finished, auto_started and the celebration`() {
|
||||
val state = decode<TimerStateDto>("timer_complete_reply.json")
|
||||
assertEquals(412L, state.finished?.id)
|
||||
assertEquals("completed", state.finished?.status)
|
||||
assertEquals(413L, state.autoStarted?.id)
|
||||
assertEquals("short_break", state.autoStarted?.kind)
|
||||
assertNotNull(state.celebration)
|
||||
assertEquals(413L, state.current?.id, "current is the follow-on interval")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an inactive policy hides its lists but keeps all_domains`() {
|
||||
// The single most important API detail for the focus guard: `domains` and `apps`
|
||||
// are empty whenever `active` is false, so a client that reads them would think
|
||||
// the block list was empty and enforce nothing.
|
||||
val policy = decode<BlockerPolicyDto>("blocker_policy_inactive.json")
|
||||
assertTrue(!policy.active)
|
||||
assertTrue(policy.domains.isEmpty())
|
||||
assertTrue(policy.apps.isEmpty())
|
||||
assertEquals(3, policy.allDomains.size)
|
||||
assertEquals(2, policy.allApps.size)
|
||||
assertNull(policy.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an active policy populates both`() {
|
||||
val policy = decode<BlockerPolicyDto>("blocker_policy_active.json")
|
||||
assertTrue(policy.active)
|
||||
assertEquals(policy.allDomains, policy.domains)
|
||||
assertEquals(listOf("*gram*"), policy.unexpandable)
|
||||
assertEquals(412L, policy.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tasks decode with their buckets and sources`() {
|
||||
val list = decode<TaskListDto>("tasks.json")
|
||||
assertEquals(3, list.tasks.size)
|
||||
assertEquals("overdue", list.tasks[0].bucket)
|
||||
assertEquals(3, list.tasks[0].daysOverdue)
|
||||
assertTrue(list.tasks[0].pinned)
|
||||
assertEquals("local", list.tasks[2].source)
|
||||
assertEquals(1, list.counts["overdue"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the daily report decodes whole`() {
|
||||
val envelope = decode<DailyReportEnvelopeDto>("daily_report.json")
|
||||
val report = envelope.report
|
||||
assertEquals(6, report.metrics.pomodoros)
|
||||
assertEquals(155, report.metrics.focusMinutes)
|
||||
assertEquals(80, report.metrics.longestStretchMinutes)
|
||||
assertEquals(2, report.achievements.size)
|
||||
assertEquals("deep_work", report.achievements[0].code)
|
||||
assertEquals(2, report.funFacts.size)
|
||||
assertEquals(72.4, report.score)
|
||||
assertEquals(4, report.streak.current)
|
||||
assertEquals(16, report.metrics.blockedAttempts)
|
||||
assertNull(envelope.sentAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stats summary decodes its ranges`() {
|
||||
val stats = decode<StatsSummaryDto>("stats_summary.json")
|
||||
assertEquals(3, stats.range.size)
|
||||
assertEquals(2, stats.heatmap.size)
|
||||
assertEquals(162.5, stats.lifetime.focusHours)
|
||||
assertEquals(5.4, stats.average7d.pomodoros)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `block events decode both kinds`() {
|
||||
val events = decode<BlockEventsDto>("block_events.json")
|
||||
assertEquals(16, events.total)
|
||||
assertEquals("domain", events.events[0].kind)
|
||||
assertEquals("killed", events.events[1].action)
|
||||
assertEquals("youtube.com", events.byTarget[0].target)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auth me decodes`() {
|
||||
val me = decode<AuthMeDto>("auth_me.json")
|
||||
assertTrue(me.authenticated)
|
||||
assertTrue(me.loginRequired)
|
||||
assertEquals("timka", me.username)
|
||||
}
|
||||
|
||||
// --- the corruption passes ------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `an empty object decodes into every DTO`() {
|
||||
// A stripped-down or errored response must still produce usable defaults.
|
||||
assertNull(KadenceJson.decodeFromString<TimerStateDto>("{}").current)
|
||||
assertEquals(8, KadenceJson.decodeFromString<TimerStateDto>("{}").goal)
|
||||
assertTrue(KadenceJson.decodeFromString<TaskListDto>("{}").tasks.isEmpty())
|
||||
assertTrue(!KadenceJson.decodeFromString<BlockerPolicyDto>("{}").active)
|
||||
assertEquals(0.0, KadenceJson.decodeFromString<DailyReportEnvelopeDto>("{}").report.score)
|
||||
assertEquals(0, KadenceJson.decodeFromString<StatsSummaryDto>("{}").range.size)
|
||||
assertEquals(0, KadenceJson.decodeFromString<BlockEventsDto>("{}").total)
|
||||
assertTrue(!KadenceJson.decodeFromString<AuthMeDto>("{}").authenticated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown keys are ignored everywhere`() {
|
||||
val grown = withUnknownKeys(fixture("timer_state_running.json"))
|
||||
assertEquals(412L, KadenceJson.decodeFromString<TimerStateDto>(grown).current?.id)
|
||||
|
||||
val policy = withUnknownKeys(fixture("blocker_policy_active.json"))
|
||||
assertTrue(KadenceJson.decodeFromString<BlockerPolicyDto>(policy).active)
|
||||
|
||||
val tasks = withUnknownKeys(fixture("tasks.json"))
|
||||
assertEquals(3, KadenceJson.decodeFromString<TaskListDto>(tasks).tasks.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nulls where a value is declared fall back to defaults`() {
|
||||
// `coerceInputValues` is what saves us here; without it this throws.
|
||||
val nulled = allNulls(fixture("timer_state_running.json"))
|
||||
val state = KadenceJson.decodeFromString<TimerStateDto>(nulled)
|
||||
assertNull(state.current)
|
||||
assertEquals(8, state.goal, "the default survives a null")
|
||||
assertEquals("work", state.nextKind)
|
||||
assertTrue(state.timerEnabled)
|
||||
|
||||
val policy = KadenceJson.decodeFromString<BlockerPolicyDto>(
|
||||
allNulls(fixture("blocker_policy_active.json")),
|
||||
)
|
||||
assertTrue(!policy.active)
|
||||
assertTrue(policy.allDomains.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a nested object arriving as the wrong shape does not take the page down`() {
|
||||
// A task row missing almost everything still decodes, because every field has
|
||||
// a default — the list renders with blanks instead of crashing.
|
||||
val json = """{"date":"2026-10-19","tasks":[{"id":9}],"counts":{},"sources":[]}"""
|
||||
val list = KadenceJson.decodeFromString<TaskListDto>(json)
|
||||
assertEquals(1, list.tasks.size)
|
||||
assertEquals(9L, list.tasks[0].id)
|
||||
assertEquals("", list.tasks[0].title)
|
||||
assertEquals("someday", list.tasks[0].bucket)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every fixture round-trips through the parser`() {
|
||||
// Cheap guard that no fixture has drifted into invalid JSON.
|
||||
val names = listOf(
|
||||
"timer_state_running.json", "timer_state_idle.json", "timer_complete_reply.json",
|
||||
"blocker_policy_active.json", "blocker_policy_inactive.json", "tasks.json",
|
||||
"daily_report.json", "stats_summary.json", "block_events.json", "auth_me.json",
|
||||
)
|
||||
for (name in names) {
|
||||
val parsed = KadenceJson.parseToJsonElement(fixture(name))
|
||||
assertTrue(parsed is JsonObject, "$name should be a JSON object")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package tech.kadence.android
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mockwebserver3.MockResponse
|
||||
import mockwebserver3.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import tech.kadence.android.data.dto.TimerStateDto
|
||||
import tech.kadence.android.data.net.ApiResult
|
||||
import tech.kadence.android.data.net.AuthConfig
|
||||
import tech.kadence.android.data.net.KadenceApi
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The HTTP layer, against a mock server.
|
||||
*
|
||||
* Nothing here needs the real backend running — the backend lives only on the dev
|
||||
* machine, so a suite that required it would be unrunnable anywhere else. MockWebServer
|
||||
* serves the committed fixtures instead.
|
||||
*/
|
||||
class KadenceApiTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private var token: String? = "s3cret-token"
|
||||
|
||||
private val api: KadenceApi by lazy {
|
||||
KadenceApi(config = {
|
||||
object : AuthConfig {
|
||||
override val baseUrl = server.url("/").toString().trimEnd('/')
|
||||
override val apiToken = token
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name"))
|
||||
.bufferedReader().readText()
|
||||
|
||||
private fun json(code: Int, body: String) =
|
||||
MockResponse.Builder()
|
||||
.code(code)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.build()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the api token goes out as X-API-Token, like the desktop client`() = runBlocking {
|
||||
server.enqueue(json(200, fixture("timer_state_running.json")))
|
||||
|
||||
val result = api.get<TimerStateDto>("/api/timer/state")
|
||||
assertIs<ApiResult.Ok<TimerStateDto>>(result)
|
||||
assertEquals(412L, result.value.current?.id)
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals("s3cret-token", recorded.headers["X-API-Token"])
|
||||
assertEquals("application/json", recorded.headers["Accept"])
|
||||
assertEquals("/api/timer/state", recorded.target)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no token means no header, for a backend that wants none`() = runBlocking {
|
||||
token = null
|
||||
server.enqueue(json(200, fixture("timer_state_idle.json")))
|
||||
|
||||
api.get<TimerStateDto>("/api/timer/state")
|
||||
|
||||
assertNull(server.takeRequest().headers["X-API-Token"])
|
||||
token = "s3cret-token"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the session cookie is kept and sent back`() = runBlocking {
|
||||
server.enqueue(
|
||||
MockResponse.Builder()
|
||||
.code(200)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.addHeader("Set-Cookie", "kadence_session=abc123; Path=/; HttpOnly; Max-Age=2592000")
|
||||
.body("""{"ok":true}""")
|
||||
.build(),
|
||||
)
|
||||
server.enqueue(json(200, fixture("timer_state_running.json")))
|
||||
|
||||
api.post<Map<String, Boolean>>("/api/auth/login", """{"username":"timka","password":"x"}""")
|
||||
server.takeRequest()
|
||||
|
||||
api.get<TimerStateDto>("/api/timer/state")
|
||||
val second = server.takeRequest()
|
||||
|
||||
assertTrue(
|
||||
second.headers["Cookie"]?.contains("kadence_session=abc123") == true,
|
||||
"the session cookie should ride along: ${second.headers["Cookie"]}",
|
||||
)
|
||||
assertEquals("abc123", api.cookieJar.named("kadence_session")?.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 on a normal path notifies so the UI can drop to login`() = runBlocking {
|
||||
var notified = 0
|
||||
api.unauthorized.subscribe { notified++ }
|
||||
|
||||
server.enqueue(json(401, """{"detail":"not authenticated"}"""))
|
||||
val result = api.get<TimerStateDto>("/api/timer/state")
|
||||
|
||||
assertIs<ApiResult.Unauthorized>(result)
|
||||
assertEquals("not authenticated", result.detail)
|
||||
assertEquals(1, notified)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 on the login path does not, or signing in would loop`() = runBlocking {
|
||||
var notified = 0
|
||||
api.unauthorized.subscribe { notified++ }
|
||||
|
||||
server.enqueue(json(401, """{"detail":"wrong password"}"""))
|
||||
val result = api.post<Map<String, Boolean>>("/api/auth/login", """{"username":"a"}""")
|
||||
|
||||
assertIs<ApiResult.HttpError>(result)
|
||||
assertEquals(401, result.status)
|
||||
assertEquals("wrong password", result.detail)
|
||||
assertEquals(0, notified, "a failed sign-in must not trigger the re-auth bus")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain detail string is surfaced as the message`() = runBlocking {
|
||||
// What `TimerError` produces, e.g. refusing to pause when pausing is off.
|
||||
server.enqueue(json(400, """{"detail":"Pausing is switched off in your settings."}"""))
|
||||
|
||||
val result = api.post<TimerStateDto>("/api/timer/pause")
|
||||
assertIs<ApiResult.HttpError>(result)
|
||||
assertEquals(400, result.status)
|
||||
assertEquals("Pausing is switched off in your settings.", result.detail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 422 validation body renders as field colon message`() = runBlocking {
|
||||
server.enqueue(
|
||||
json(
|
||||
422,
|
||||
"""{"detail":[
|
||||
{"loc":["body","minutes"],"msg":"Input should be less than or equal to 240"},
|
||||
{"loc":["body","kind"],"msg":"Input should be 'work', 'short_break' or 'long_break'"}
|
||||
]}""",
|
||||
),
|
||||
)
|
||||
|
||||
val result = api.post<TimerStateDto>("/api/timer/start", """{"minutes":9999}""")
|
||||
assertIs<ApiResult.HttpError>(result)
|
||||
assertEquals(422, result.status)
|
||||
assertEquals(
|
||||
"minutes: Input should be less than or equal to 240; " +
|
||||
"kind: Input should be 'work', 'short_break' or 'long_break'",
|
||||
result.detail,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-JSON error body falls back to the status line`() = runBlocking {
|
||||
server.enqueue(
|
||||
MockResponse.Builder().code(502).body("<html>Bad Gateway</html>").build(),
|
||||
)
|
||||
|
||||
val result = api.get<TimerStateDto>("/api/timer/state")
|
||||
assertIs<ApiResult.HttpError>(result)
|
||||
assertEquals(502, result.status)
|
||||
assertTrue(result.detail.startsWith("502"), "got: ${result.detail}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreachable backend is a NetworkError, never a crash`() = runBlocking {
|
||||
server.close() // nothing listening any more
|
||||
|
||||
val result = api.get<TimerStateDto>("/api/timer/state")
|
||||
assertIs<ApiResult.NetworkError>(result)
|
||||
|
||||
// Restart so tearDown has something to close.
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a successful but unreadable body does not crash the caller`() = runBlocking {
|
||||
server.enqueue(json(200, """{"current": "this should be an object"}"""))
|
||||
|
||||
val result = api.get<TimerStateDto>("/api/timer/state")
|
||||
assertIs<ApiResult.HttpError>(result)
|
||||
assertTrue(result.detail.startsWith("unreadable response"), "got: ${result.detail}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a POST with no body still sends an empty object`() = runBlocking {
|
||||
// FastAPI's request models default every field, but an absent body on a POST
|
||||
// that declares one is a 422; the desktop client sends `{}` for the same reason.
|
||||
server.enqueue(json(200, fixture("timer_state_running.json")))
|
||||
|
||||
api.post<TimerStateDto>("/api/timer/resume")
|
||||
val recorded = server.takeRequest()
|
||||
|
||||
assertEquals("POST", recorded.method)
|
||||
assertEquals("{}", recorded.body?.utf8())
|
||||
assertTrue(recorded.headers["Content-Type"]?.startsWith("application/json") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trailing slashes in the configured URL do not produce a double slash`() = runBlocking {
|
||||
server.enqueue(json(200, fixture("timer_state_idle.json")))
|
||||
|
||||
api.get<TimerStateDto>("/api/timer/state")
|
||||
|
||||
assertEquals("/api/timer/state", server.takeRequest().target)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package tech.kadence.android
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import tech.kadence.android.ui.components.TabularFigures
|
||||
import tech.kadence.android.ui.theme.KadenceTheme
|
||||
import tech.kadence.core.KADENCE_DARK
|
||||
import tech.kadence.core.KADENCE_LIGHT
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The theme and the type, on the JVM.
|
||||
*
|
||||
* Robolectric means the Compose layer is checked here rather than on the phone: the
|
||||
* palette actually reaching a composable, and — the one that matters — that the serif
|
||||
* clock really is fixed-advance despite Fraunces having no `tnum`.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ThemeTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun argb(color: Color): Long = color.value.toLong() ushr 32 and 0xFFFFFFFFL
|
||||
|
||||
@Test
|
||||
fun `the light palette reaching a composable is the core palette`() {
|
||||
var paper: Color? = null
|
||||
var ink: Color? = null
|
||||
var rule: Color? = null
|
||||
|
||||
compose.setContent {
|
||||
KadenceTheme(dark = false, dynamicAccent = false) {
|
||||
paper = KadenceTheme.colors.paper
|
||||
ink = KadenceTheme.colors.ink
|
||||
rule = KadenceTheme.colors.rule
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(KADENCE_LIGHT.paper, argb(paper!!), "paper")
|
||||
assertEquals(KADENCE_LIGHT.ink, argb(ink!!), "ink")
|
||||
assertEquals(KADENCE_LIGHT.rule, argb(rule!!), "rule keeps its 12% alpha")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the dark palette is the dark one, not a tinted light`() {
|
||||
var paper: Color? = null
|
||||
compose.setContent {
|
||||
KadenceTheme(dark = true, dynamicAccent = false) {
|
||||
paper = KadenceTheme.colors.paper
|
||||
}
|
||||
}
|
||||
assertEquals(KADENCE_DARK.paper, argb(paper!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without dynamic colour the accent is the brand ember`() {
|
||||
var accent: Color? = null
|
||||
var onAccent: Color? = null
|
||||
compose.setContent {
|
||||
KadenceTheme(dark = false, dynamicAccent = false) {
|
||||
accent = KadenceTheme.colors.accent
|
||||
onAccent = KadenceTheme.colors.onAccent
|
||||
}
|
||||
}
|
||||
assertEquals(KADENCE_LIGHT.ember, argb(accent!!))
|
||||
assertEquals(0xFFFFFFFFL, argb(onAccent!!), "white sits on ember")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the chart palette does not follow the accent`() {
|
||||
var work: Color? = null
|
||||
var accent: Color? = null
|
||||
compose.setContent {
|
||||
KadenceTheme(dark = false, dynamicAccent = true) {
|
||||
work = KadenceTheme.viz.work
|
||||
accent = KadenceTheme.colors.accent
|
||||
}
|
||||
}
|
||||
assertEquals(0xFFEB6834L, argb(work!!), "work keeps its validated orange")
|
||||
assertTrue(work != accent || argb(accent!!) == 0xFFEB6834L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the serif clock is fixed-advance despite Fraunces having no tnum`() {
|
||||
// The whole point of TabularFigures. "11:11" is the narrowest possible set of
|
||||
// digits and "00:00" among the widest; with proportional figures these would
|
||||
// differ by roughly 30% and the dial would visibly jiggle every second.
|
||||
compose.setContent {
|
||||
KadenceTheme(dark = false, dynamicAccent = false) {
|
||||
Row {
|
||||
TabularFigures(
|
||||
text = "11:11",
|
||||
style = KadenceTheme.type.clock,
|
||||
color = KadenceTheme.colors.ink,
|
||||
modifier = Modifier.testTag("ones"),
|
||||
)
|
||||
TabularFigures(
|
||||
text = "00:00",
|
||||
style = KadenceTheme.type.clock,
|
||||
color = KadenceTheme.colors.ink,
|
||||
modifier = Modifier.testTag("zeros"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ones = compose.onNodeWithTag("ones").getUnclippedBoundsInRoot()
|
||||
val zeros = compose.onNodeWithTag("zeros").getUnclippedBoundsInRoot()
|
||||
val onesWidth = (ones.right - ones.left).value
|
||||
val zerosWidth = (zeros.right - zeros.left).value
|
||||
|
||||
// Guard against a stubbed text measurer: if both came back zero the comparison
|
||||
// below would pass without proving anything.
|
||||
assertTrue(onesWidth > 0f, "the figures did not actually measure")
|
||||
assertTrue(
|
||||
abs(onesWidth - zerosWidth) < 0.5f,
|
||||
"11:11 measured $onesWidth dp and 00:00 measured $zerosWidth dp — " +
|
||||
"the clock would jiggle every second",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"authenticated": true,
|
||||
"login_required": true,
|
||||
"username": "timka",
|
||||
"password_login_enabled": true,
|
||||
"telegram_login_enabled": false,
|
||||
"telegram_bot_username": ""
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"date": "2026-10-19",
|
||||
"total": 16,
|
||||
"by_target": [{"target": "youtube.com", "count": 12}, {"target": "reddit.com", "count": 4}],
|
||||
"events": [
|
||||
{"id": 90, "kind": "domain", "target": "youtube.com", "action": "blocked", "at": "2026-10-19T10:04:00+00:00", "details": "chrome"},
|
||||
{"id": 91, "kind": "app", "target": "*discord*", "action": "killed", "at": "2026-10-19T10:06:00+00:00", "details": "Discord (com.discord)"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"active": true,
|
||||
"reason": "a work interval is running",
|
||||
"mode": "during_work",
|
||||
"enabled": true,
|
||||
"strict": false,
|
||||
"redirect_ip": "127.0.0.1",
|
||||
"domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
|
||||
"apps": ["steam*", "*discord*"],
|
||||
"unexpandable": ["*gram*"],
|
||||
"kill_apps": true,
|
||||
"notify_on_block": true,
|
||||
"poll_seconds": 5,
|
||||
"session_id": 412,
|
||||
"all_domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
|
||||
"all_apps": ["steam*", "*discord*"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"active": false,
|
||||
"reason": "no work interval is running",
|
||||
"mode": "during_work",
|
||||
"enabled": true,
|
||||
"strict": false,
|
||||
"redirect_ip": "127.0.0.1",
|
||||
"domains": [],
|
||||
"apps": [],
|
||||
"unexpandable": ["*gram*"],
|
||||
"kill_apps": true,
|
||||
"notify_on_block": true,
|
||||
"poll_seconds": 5,
|
||||
"session_id": null,
|
||||
"all_domains": ["youtube.com", "www.youtube.com", "m.youtube.com"],
|
||||
"all_apps": ["steam*", "*discord*"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"report": {
|
||||
"date": "2026-10-19",
|
||||
"metrics": {
|
||||
"date": "2026-10-19", "pomodoros": 6, "goal": 8, "goal_pct": 75.0, "goal_met": false,
|
||||
"focus_minutes": 155, "break_minutes": 35, "wasted_minutes": 12, "abandoned": 1,
|
||||
"breaks_taken": 4, "interruptions": 3, "completion_rate": 0.86,
|
||||
"longest_stretch_minutes": 80, "first_start": "2026-10-19T09:12:00+00:00",
|
||||
"last_end": "2026-10-19T18:40:00+00:00",
|
||||
"by_hour": [{"hour": 9, "count": 1}, {"hour": 10, "count": 2}, {"hour": 14, "count": 3}],
|
||||
"peak_hour": 10,
|
||||
"tasks_completed": [
|
||||
{"id": 1, "title": "Fix the flaky importer", "source": "jira", "project": "Platform", "url": "https://example.atlassian.net/browse/PLAT-184"}
|
||||
],
|
||||
"tasks_completed_count": 1,
|
||||
"time_by_task": [{"title": "Fix the flaky importer", "minutes": 75}],
|
||||
"blocked_attempts": 16,
|
||||
"blocked_top": [{"target": "youtube.com", "count": 12}, {"target": "reddit.com", "count": 4}],
|
||||
"sessions": [
|
||||
{"id": 412, "kind": "work", "status": "completed", "started_at": "2026-10-19T09:12:00+00:00", "minutes": 25, "task": "Fix the flaky importer", "interruptions": 1}
|
||||
]
|
||||
},
|
||||
"streak": {"current": 4, "best": 11, "goal_streak": 2, "total_days": 63},
|
||||
"average_7d": {"pomodoros": 5.4, "focus_minutes": 142.3},
|
||||
"lifetime": {"pomodoros": 390, "focus_minutes": 9750, "focus_hours": 162.5, "tasks_completed": 88, "blocked_attempts": 512},
|
||||
"comparison": {"yesterday": 7, "previous_best": 11},
|
||||
"achievements": [
|
||||
{"code": "deep_work", "emoji": "🧘", "title": "Deep work", "detail": "80 minutes without breaking the chain."},
|
||||
{"code": "gatekeeper", "emoji": "🛡️", "title": "Gatekeeper", "detail": "The blocker turned you away 16 times. It won."}
|
||||
],
|
||||
"fun_facts": [
|
||||
"155 minutes of focus — that is 7.0 sitcom episodes you did not watch.",
|
||||
"Your peak hour was 10:00. Defend it."
|
||||
],
|
||||
"verdict": "Target hit. Now protect the streak. 💪",
|
||||
"open_tasks": {"overdue": 1, "today": 1, "someday": 1},
|
||||
"score": 72.4
|
||||
},
|
||||
"sent_at": null,
|
||||
"telegram_preview": "🍅 Daily report — 2026-10-19"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"today": {
|
||||
"date": "2026-10-19", "pomodoros": 6, "goal": 8, "goal_pct": 75.0, "goal_met": false,
|
||||
"focus_minutes": 155, "break_minutes": 35, "interruptions": 3,
|
||||
"by_hour": [{"hour": 10, "count": 2}], "peak_hour": 10,
|
||||
"blocked_attempts": 16, "sessions": []
|
||||
},
|
||||
"streak": {"current": 4, "best": 11, "goal_streak": 2, "total_days": 63},
|
||||
"lifetime": {"pomodoros": 390, "focus_minutes": 9750, "focus_hours": 162.5, "tasks_completed": 88, "blocked_attempts": 512},
|
||||
"average_7d": {"pomodoros": 5.4, "focus_minutes": 142.3},
|
||||
"range": [
|
||||
{"date": "2026-10-17", "pomodoros": 5, "focus_minutes": 125, "goal_met": false},
|
||||
{"date": "2026-10-18", "pomodoros": 7, "focus_minutes": 175, "goal_met": false},
|
||||
{"date": "2026-10-19", "pomodoros": 6, "focus_minutes": 155, "goal_met": false}
|
||||
],
|
||||
"heatmap": [
|
||||
{"date": "2026-10-13", "pomodoros": 8, "focus_minutes": 200, "goal_met": true},
|
||||
{"date": "2026-10-14", "pomodoros": 0, "focus_minutes": 0, "goal_met": false}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"date": "2026-10-19",
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1, "source": "jira", "source_label": "Jira", "external_id": "PLAT-184",
|
||||
"title": "Fix the flaky importer", "description": null,
|
||||
"url": "https://example.atlassian.net/browse/PLAT-184",
|
||||
"project": "Platform", "status": "In Progress", "priority": "High",
|
||||
"due_date": "2026-10-16", "start_date": null, "labels": ["backend"],
|
||||
"is_done": false, "pinned": true, "stale": false,
|
||||
"estimated_pomodoros": 3, "days_overdue": 3, "bucket": "overdue"
|
||||
},
|
||||
{
|
||||
"id": 2, "source": "todoist", "source_label": "Todoist", "external_id": "7788",
|
||||
"title": "Write the design doc", "description": null, "url": null,
|
||||
"project": "Work", "status": null, "priority": "p2",
|
||||
"due_date": "2026-10-19", "start_date": null, "labels": [],
|
||||
"is_done": false, "pinned": false, "stale": false,
|
||||
"estimated_pomodoros": null, "days_overdue": 0, "bucket": "today"
|
||||
},
|
||||
{
|
||||
"id": 3, "source": "local", "source_label": "Local", "external_id": "local-3",
|
||||
"title": "And this too", "description": null, "url": null,
|
||||
"project": null, "status": null, "priority": null,
|
||||
"due_date": null, "start_date": null, "labels": [],
|
||||
"is_done": false, "pinned": false, "stale": false,
|
||||
"estimated_pomodoros": null, "days_overdue": 0, "bucket": "someday"
|
||||
}
|
||||
],
|
||||
"counts": {"overdue": 1, "today": 1, "active": 0, "upcoming": 0, "someday": 1},
|
||||
"sources": ["jira", "todoist", "local"]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"current": {
|
||||
"id": 413,
|
||||
"kind": "short_break",
|
||||
"status": "running",
|
||||
"started_at": "2026-10-19T09:37:04+00:00",
|
||||
"ended_at": null,
|
||||
"planned_seconds": 300,
|
||||
"elapsed_seconds": 0,
|
||||
"remaining_seconds": 300,
|
||||
"paused_seconds": 0,
|
||||
"interruptions": 0,
|
||||
"cycle_index": 3,
|
||||
"task": null,
|
||||
"note": null
|
||||
},
|
||||
"finished": {
|
||||
"id": 412,
|
||||
"kind": "work",
|
||||
"status": "completed",
|
||||
"started_at": "2026-10-19T09:12:04+00:00",
|
||||
"ended_at": "2026-10-19T09:37:04+00:00",
|
||||
"planned_seconds": 1500,
|
||||
"elapsed_seconds": 1500,
|
||||
"remaining_seconds": 0,
|
||||
"paused_seconds": 0,
|
||||
"interruptions": 1,
|
||||
"cycle_index": 2,
|
||||
"task": {"source": "jira", "external_id": "PLAT-184", "title": "Fix the flaky importer"},
|
||||
"note": null
|
||||
},
|
||||
"auto_started": {
|
||||
"id": 413,
|
||||
"kind": "short_break",
|
||||
"status": "running",
|
||||
"started_at": "2026-10-19T09:37:04+00:00",
|
||||
"planned_seconds": 300,
|
||||
"remaining_seconds": 300,
|
||||
"interruptions": 0,
|
||||
"cycle_index": 3,
|
||||
"task": null
|
||||
},
|
||||
"celebration": "Seven down. The afternoon is yours to lose.",
|
||||
"next_kind": "work",
|
||||
"completed_today": 7,
|
||||
"goal": 8,
|
||||
"cycle_position": 3,
|
||||
"cycles_before_long_break": 4,
|
||||
"durations": {"work": 25, "short_break": 5, "long_break": 15},
|
||||
"server_time": "2026-10-19T09:37:04+00:00",
|
||||
"timer_enabled": true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"current": null,
|
||||
"next_kind": "work",
|
||||
"completed_today": 0,
|
||||
"goal": 8,
|
||||
"cycle_position": 0,
|
||||
"cycles_before_long_break": 4,
|
||||
"durations": {"work": 45, "short_break": 15, "long_break": 30},
|
||||
"server_time": "2026-10-19T08:00:00+00:00",
|
||||
"timer_enabled": true
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"current": {
|
||||
"id": 412,
|
||||
"kind": "work",
|
||||
"status": "running",
|
||||
"started_at": "2026-10-19T09:12:04+00:00",
|
||||
"ended_at": null,
|
||||
"planned_seconds": 1500,
|
||||
"elapsed_seconds": 396,
|
||||
"remaining_seconds": 1104,
|
||||
"paused_seconds": 0,
|
||||
"interruptions": 1,
|
||||
"cycle_index": 2,
|
||||
"task": {
|
||||
"source": "jira",
|
||||
"external_id": "PLAT-184",
|
||||
"title": "Fix the flaky importer"
|
||||
},
|
||||
"note": null
|
||||
},
|
||||
"next_kind": "short_break",
|
||||
"completed_today": 6,
|
||||
"goal": 8,
|
||||
"cycle_position": 2,
|
||||
"cycles_before_long_break": 4,
|
||||
"durations": {"work": 25, "short_break": 5, "long_break": 15},
|
||||
"server_time": "2026-10-19T09:18:40+00:00",
|
||||
"timer_enabled": true
|
||||
}
|
||||
Reference in new issue
Block a user