some backend fixes and add android app

This commit is contained in:
timka123 committed 2026-10-19 14:52:20 +03:00
1 parent 772fdbfded
commit 1e55144291
69 files changed
+7111 -7

No files matched your search

+30
View File
@@ -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
}