some backend fixes and add android app
This commit is contained in:
69 files changed
+7111
-7
No files matched your search
@@ -23,3 +23,11 @@ backend/static/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
tsconfig.tsbuildinfo
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/build/
|
||||
android/*/build/
|
||||
android/local.properties
|
||||
android/app/libs/*.aar
|
||||
android/glyph/libs/*.aar
|
||||
*.keystore
|
||||
@@ -36,10 +36,22 @@ desktop-install: ## Install the launcher entry and icon for your user
|
||||
helper-install: ## Install the privileged blocking helper (asks for sudo)
|
||||
sudo ./desktop/helper/install.sh
|
||||
|
||||
android-test: ## Run every Android test (no device, no backend needed)
|
||||
cd android && ./gradlew :core:test :app:testDebugUnitTest
|
||||
|
||||
android-test-core: ## Run just the pure-logic suite (needs only a JDK)
|
||||
cd android && ./gradlew :core:test
|
||||
|
||||
android-build: ## Build the debug APK (needs the Android SDK)
|
||||
cd android && ./gradlew :app:assembleDebug
|
||||
|
||||
android-install: ## Install the debug APK on the connected phone
|
||||
cd android && ./gradlew :app:installDebug
|
||||
|
||||
report: ## Build and send today's report right now
|
||||
cd backend && PYTHONPATH=$$PWD .venv/bin/python -m app.cli report
|
||||
|
||||
set-password: ## Set/rotate the web login password (asks for a confirm)
|
||||
docker compose exec app python -m app.cli set-password
|
||||
|
||||
.PHONY: help up down logs dev test backend-venv web-build desktop desktop-install helper-install report set-password
|
||||
.PHONY: help up down logs dev test backend-venv web-build desktop desktop-install helper-install android-test android-test-core android-build android-install report set-password
|
||||
@@ -0,0 +1,127 @@
|
||||
# Kadence for Android
|
||||
|
||||
The fourth client, for the Nothing Phone (3). Same backend, same editorial design, plus
|
||||
the three things only a native phone client can do: enforce the focus guard on the phone
|
||||
itself, surface the running interval as an Android 16 Live Update, and draw the timer on
|
||||
the rear Glyph Matrix.
|
||||
|
||||
## Two modules, and why
|
||||
|
||||
| Module | What it is | Needs the Android SDK? |
|
||||
| --- | --- | --- |
|
||||
| **`core`** | Plain Kotlin on the JVM: the matchers, the policy clock, the timer maths, the outbox reconciliation, the Glyph frame renderer, the design tokens | **No** |
|
||||
| **`app`** | The Android client: Compose UI, services, Room, the HTTP client, the Glyph toy | Yes |
|
||||
|
||||
Everything with interesting behaviour lives in `core`, deliberately. The result is that
|
||||
the hard parts — offline reconciliation, glob and mask matching, the blocking policy,
|
||||
even the 25×25 rear display — are **testable on a laptop with nothing but a JDK**, with
|
||||
no emulator, no SDK and no phone in the loop:
|
||||
|
||||
```bash
|
||||
make android-test # both modules: 219 tests, no device, no backend
|
||||
make android-test-core # just the pure-logic suite: needs only a JDK
|
||||
```
|
||||
|
||||
`settings.gradle.kts` only includes `:app` when an SDK is actually present, so that
|
||||
command works on a machine that has never seen the Android tooling.
|
||||
|
||||
## Running the tests
|
||||
|
||||
```
|
||||
./gradlew :core:test :app:testDebugUnitTest
|
||||
```
|
||||
|
||||
**219 tests, none of which need a device or a running backend.** The backend lives only
|
||||
on the dev machine, so the suite feeds on committed JSON fixtures under
|
||||
`app/src/test/resources/fixtures/` and a `MockWebServer`; there is no step that curls a
|
||||
live server.
|
||||
|
||||
`:core` (186 tests):
|
||||
|
||||
| Suite | What it pins down |
|
||||
| --- | --- |
|
||||
| `GlobTest` | Python `fnmatch` parity — expectations generated from CPython, because the block lists are shared with the backend |
|
||||
| `BlockerTest` | `blocker_service.py` parity: `expand_entry`, allow-list subtraction, the policy decision and its reason strings, schedule windows incl. midnight wrap |
|
||||
| `DomainMatcherTest` / `AppMatcherTest` | hostname and app matching, allow-list precedence, and the masks a hosts file cannot express |
|
||||
| `NormalizeHostTest` | what a browser URL bar actually hands over: bare hosts, full URLs, search phrases, `chrome://`, punycode |
|
||||
| `ReconcileTest` | one test per row of the conflict matrix — another device taking over, our own replay landing, view-only mode |
|
||||
| `OutboxCoalesceTest` / `PostconditionTest` / `PartitionForReplayTest` | the offline queue: coalescing, the at-most-once probes, and quarantining intervals that cannot be filed honestly |
|
||||
| `TickMathTest` / `DialGeometryTest` | the countdown anchor and the dial's tick rule |
|
||||
| `Glyph*Test` | the rear matrix, asserted as ASCII art |
|
||||
| `TokenParityTest` | reads `frontend/src/tokens.ts` and `lib/viz.ts` and fails if a single colour channel drifts |
|
||||
| `AccentTest` | the system-accent policy and its contrast guard |
|
||||
|
||||
`:app` (33 tests, on the JVM via Robolectric):
|
||||
|
||||
| Suite | What it pins down |
|
||||
| --- | --- |
|
||||
| `DtoToleranceTest` | every endpoint's fixture decodes — then decodes again with keys stripped, unknown keys added and nulls where values are declared. The backend has no response models, so this is the contract |
|
||||
| `KadenceApiTest` | `X-API-Token`, the session cookie, 401 routing (and *not* routing on the login path), both FastAPI `detail` shapes, a 422 rendered as `field: message`, an unreachable server as a typed error |
|
||||
| `ThemeTest` | the palette actually reaching a composable, and that the serif clock is genuinely fixed-advance |
|
||||
|
||||
## Toolchain
|
||||
|
||||
Gradle 9.6 (via the wrapper) on **JDK 21** — newer JDKs are ahead of what AGP
|
||||
supports:
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
|
||||
```
|
||||
|
||||
For the `app` module you also need an Android SDK. AGP 9.4 with Compose 1.12 wants
|
||||
`compileSdk 37`, which is published under Android's new minor-version scheme as
|
||||
`platforms;android-37.0` (plain `android-37` does not exist). `targetSdk` stays 36,
|
||||
because Android 16 is what the phone runs. Point the build at the SDK with
|
||||
`ANDROID_HOME`, or `android/local.properties`:
|
||||
|
||||
```
|
||||
sdk.dir=/home/you/Android/Sdk
|
||||
```
|
||||
|
||||
## The Glyph Matrix SDK
|
||||
|
||||
`glyph-matrix-sdk-2.0.aar` is **not on Maven** — it has to be downloaded from
|
||||
[Nothing's developer kit](https://github.com/Nothing-Developer-Programme/GlyphMatrix-Developer-Kit)
|
||||
and dropped into `glyph/libs/`. It is git-ignored, and the Glyph code sits behind an
|
||||
interface with a no-op implementation, so a missing `.aar` disables the rear display and
|
||||
breaks nothing else. The renderer itself is in `core` and needs none of this to be
|
||||
tested.
|
||||
|
||||
## Design notes
|
||||
|
||||
* **Fraunces has no tabular figures.** Its digits are proportional — `1` is 1024 units
|
||||
against `0` at 1461 — and the font ships no `tnum` feature, so the web app's global
|
||||
`font-variant-numeric: tabular-nums` does nothing for the serif dial clock. Here the
|
||||
clock goes through `TabularFigures`, which lays every glyph in a box as wide as the
|
||||
widest digit; `ThemeTest` measures it. Inter *does* ship `tnum`, so the sans numbers
|
||||
are fixed with a font feature instead.
|
||||
* **The canvas is Kadence's, the accent is the phone's.** Warm paper, ink type, hairline
|
||||
rules and Fraunces figures are fixed; the single accent comes from Android's dynamic
|
||||
palette when available, falling back to the brand ember. `onAccentArgb()` picks the
|
||||
label colour by WCAG contrast, because a wallpaper-derived accent can be any lightness
|
||||
— that guard is what keeps "use the system colour" from producing unreadable buttons.
|
||||
* **Chart colours never follow the accent.** They come from the validated, colour-blind
|
||||
safe palette in `viz.ts`. A chart has to stay readable; that is a property of the
|
||||
palette, not of the wallpaper.
|
||||
* **No `VpnService`, ever.** Android allows exactly one, and it belongs to the user's
|
||||
always-on VPN. Website blocking reads the browser URL bar through an accessibility
|
||||
service instead; app blocking polls `UsageStatsManager`. Neither touches the VPN slot.
|
||||
* **The server owns the clock.** The phone polls, ticks locally from a monotonic anchor,
|
||||
and re-anchors on every reply. Offline writes go to an outbox and the server's answer
|
||||
always wins the final word.
|
||||
|
||||
## Verifying on the device
|
||||
|
||||
See the plan at `~/.claude/plans/make-an-app-for-logical-russell.md` for the full manual
|
||||
script. The short version, once the SDK is installed:
|
||||
|
||||
```bash
|
||||
make up # backend + web UI on :8000
|
||||
ip -4 addr show | grep inet # find the LAN address for the phone
|
||||
make android-install # ./gradlew :app:installDebug
|
||||
adb logcat -s Kadence:*
|
||||
```
|
||||
|
||||
Note that an always-on VPN can swallow a LAN address; `adb shell curl -s
|
||||
http://<lan-ip>:8000/api/health` is the first thing to check if onboarding cannot reach
|
||||
the backend.
|
||||
@@ -0,0 +1,85 @@
|
||||
plugins {
|
||||
// AGP 9 has built-in Kotlin support, so `org.jetbrains.kotlin.android` is gone;
|
||||
// only the Compose compiler plugin is still applied explicitly.
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "tech.kadence.android"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "tech.kadence.android"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// So a debug build can sit beside a release one on the same phone.
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources.excludes += setOf("/META-INF/{AL2.0,LGPL2.1}")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Every piece of logic worth testing lives here, and it has no Android dependency.
|
||||
implementation(project(":core"))
|
||||
|
||||
implementation(libs.serialization.json)
|
||||
implementation(libs.okhttp)
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.navigation.compose)
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
|
||||
// Robolectric runs these on the JVM, so the UI layer is verifiable without a
|
||||
// device or an emulator — the same reason the logic lives in :core.
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlin.test)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.mockwebserver)
|
||||
testImplementation(platform(libs.compose.bom))
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
|
||||
androidTestImplementation(platform(libs.compose.bom))
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Plugins are applied per module; this file only declares them so the version
|
||||
// catalog resolves them once for the whole build.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm) apply false
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(libs.kotlin.test)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
testLogging {
|
||||
events("passed", "failed", "skipped")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* The blocking policy, ported so the phone can enforce it offline.
|
||||
*
|
||||
* The server decides *whether* blocking is on and *what* is on the list
|
||||
* (`backend/app/services/blocker_service.py`); every client enforces it. Online, the
|
||||
* server's `policy.active` is authoritative. Offline, this file has to reach the same
|
||||
* answer on its own — otherwise losing signal would quietly switch the focus guard off,
|
||||
* which is the one moment you least want it off.
|
||||
*
|
||||
* Two API details worth knowing, both verified against `policy()`:
|
||||
*
|
||||
* * `policy.domains` and `policy.apps` are **empty whenever `active` is false**, while
|
||||
* `all_domains` / `all_apps` are always populated. So a client must read the `all_*`
|
||||
* fields and gate on activity itself — which `desktop/.../controller.py` also does.
|
||||
* * `mode: "always"` and `"scheduled"` mean the guard runs with no timer at all, so
|
||||
* enforcement cannot be tied to the timer's lifecycle.
|
||||
*/
|
||||
package tech.kadence.core
|
||||
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/** One `blocker.schedule[]` entry. `days` is ISO weekdays with **Monday = 0**. */
|
||||
data class BlockWindow(
|
||||
val label: String = "",
|
||||
val start: String = "00:00",
|
||||
val end: String = "23:59",
|
||||
val days: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
/** The `blocker` subtree of the settings document. */
|
||||
data class BlockerRules(
|
||||
val enabled: Boolean = false,
|
||||
val mode: String = "during_work",
|
||||
val blockDuringBreaks: Boolean = false,
|
||||
val blockedDomains: List<String> = emptyList(),
|
||||
val allowedDomains: List<String> = emptyList(),
|
||||
val subdomainExpansions: List<String> = DEFAULT_SUBDOMAINS,
|
||||
val blockedApps: List<String> = emptyList(),
|
||||
val killBlockedApps: Boolean = true,
|
||||
val schedule: List<BlockWindow> = emptyList(),
|
||||
val strict: Boolean = false,
|
||||
val notifyOnBlock: Boolean = true,
|
||||
) {
|
||||
companion object {
|
||||
/** The backend's `subdomain_expansions` default. */
|
||||
val DEFAULT_SUBDOMAINS = listOf("www", "m", "mobile", "app", "web", "i", "old", "new")
|
||||
}
|
||||
}
|
||||
|
||||
/** Just enough of the running interval for the policy to be decided. */
|
||||
data class IntervalFacts(
|
||||
val kind: String,
|
||||
val status: String,
|
||||
) {
|
||||
val isActiveStatus: Boolean get() = status == "running" || status == "paused"
|
||||
val isWork: Boolean get() = kind == "work"
|
||||
}
|
||||
|
||||
/** The verdict, carrying the same `reason` string the server would have sent. */
|
||||
data class PolicyVerdict(val active: Boolean, val reason: String)
|
||||
|
||||
/**
|
||||
* `youtube.com` -> itself plus `www.` and `m.`; `*.youtube.com` -> the bare domain plus
|
||||
* every configured subdomain; `*gram*` -> nothing expandable, returned as a leftover.
|
||||
*
|
||||
* A port of `expand_entry()`. Returns `(hostnames, unexpandablePattern)`.
|
||||
*/
|
||||
fun expandEntry(raw: String, subdomains: List<String>): Pair<List<String>, String?> {
|
||||
val entry = raw.trim().lowercase().trimStart('.')
|
||||
if (entry.isEmpty()) return emptyList<String>() to null
|
||||
|
||||
if (entry.startsWith("*.")) {
|
||||
val base = entry.removePrefix("*.")
|
||||
if (base.isEmpty() || '*' in base) return emptyList<String>() to entry
|
||||
return (listOf(base) + subdomains.map { "$it.$base" }) to null
|
||||
}
|
||||
|
||||
if ('*' in entry) return emptyList<String>() to entry
|
||||
|
||||
return listOf(entry, "www.$entry", "m.$entry") to null
|
||||
}
|
||||
|
||||
/** Whether an allow-list entry — itself maskable — covers this hostname. */
|
||||
fun isAllowed(host: String, allowMasks: List<String>): Boolean =
|
||||
allowMasks.any { globMatches(it, host) }
|
||||
|
||||
/** Normalised, de-duplicated allow masks, the way `expand_domains()` prepares them. */
|
||||
fun allowMasksOf(rules: BlockerRules): List<String> = rules.allowedDomains
|
||||
.filter { it.isNotBlank() }
|
||||
.map { it.trim().lowercase().trimStart('.') }
|
||||
|
||||
private fun subdomainsOf(rules: BlockerRules): List<String> = rules.subdomainExpansions
|
||||
.filter { it.isNotBlank() }
|
||||
.map { it.trim().lowercase() }
|
||||
|
||||
/**
|
||||
* Every hostname a hosts file would be pointed at the redirect IP, allow list already
|
||||
* subtracted — the client-side twin of `expand_domains()`, used when offline and as a
|
||||
* cross-check against the server's `all_domains`.
|
||||
*/
|
||||
fun expandDomains(rules: BlockerRules): List<String> = expandDomainsWithOrigin(rules).keys.toList()
|
||||
|
||||
/**
|
||||
* The same expansion, but remembering which block-list entry produced each hostname.
|
||||
*
|
||||
* Worth keeping, because the host alone does not identify the rule: `*.reddit.com`
|
||||
* expands to the bare `reddit.com`, yet the mask does not *match* that bare name (see
|
||||
* [globMatches]). Without the provenance the overlay would have to say "blocked:
|
||||
* reddit.com" when the honest answer is "blocked by `*.reddit.com`".
|
||||
*
|
||||
* Insertion-ordered, and the first entry to claim a host keeps it.
|
||||
*/
|
||||
fun expandDomainsWithOrigin(rules: BlockerRules): Map<String, String> {
|
||||
val allowMasks = allowMasksOf(rules)
|
||||
val subdomains = subdomainsOf(rules)
|
||||
val origin = LinkedHashMap<String, String>()
|
||||
for (raw in rules.blockedDomains) {
|
||||
val entry = raw.trim().lowercase().trimStart('.')
|
||||
val (expanded, _) = expandEntry(raw, subdomains)
|
||||
for (host in expanded) {
|
||||
if (!isAllowed(host, allowMasks)) origin.putIfAbsent(host, entry)
|
||||
}
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
/** Block-list entries a hosts file cannot express, so the UI can flag them. */
|
||||
fun unexpandableMasks(rules: BlockerRules): List<String> {
|
||||
val subdomains = subdomainsOf(rules)
|
||||
val leftovers = LinkedHashSet<String>()
|
||||
for (raw in rules.blockedDomains) {
|
||||
val (_, pattern) = expandEntry(raw, subdomains)
|
||||
if (pattern != null) leftovers.add(pattern)
|
||||
}
|
||||
return leftovers.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any schedule window covers this moment.
|
||||
*
|
||||
* Both the weekday and the time of day are read in the **server's** timezone, so
|
||||
* travelling does not shift your blocking windows. (The backend's `schedule_active()`
|
||||
* takes the weekday from the process's local clock while taking the time from the
|
||||
* configured zone; reading both from one zone is the behaviour that was intended.)
|
||||
*/
|
||||
fun scheduleActive(rules: BlockerRules, now: ZonedDateTime): Boolean {
|
||||
val weekday = now.dayOfWeek.value - 1
|
||||
val moment = now.toLocalTime()
|
||||
return rules.schedule.any { window ->
|
||||
if (weekday !in window.days) return@any false
|
||||
val start = parseClockTime(window.start) ?: return@any false
|
||||
val end = parseClockTime(window.end) ?: return@any false
|
||||
inWindow(moment, start, end)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The port of `policy()`'s decision, reason strings included — so the Blocker screen
|
||||
* reads identically whether the verdict came from the server or from here.
|
||||
*/
|
||||
fun evaluatePolicy(
|
||||
rules: BlockerRules,
|
||||
current: IntervalFacts?,
|
||||
now: ZonedDateTime,
|
||||
): PolicyVerdict {
|
||||
if (!rules.enabled) return PolicyVerdict(false, "disabled")
|
||||
|
||||
return when (rules.mode) {
|
||||
"always" -> PolicyVerdict(true, "always on")
|
||||
"scheduled" -> if (scheduleActive(rules, now)) {
|
||||
PolicyVerdict(true, "inside a blocking window")
|
||||
} else {
|
||||
PolicyVerdict(false, "outside every window")
|
||||
}
|
||||
else -> when {
|
||||
current != null && current.isWork && current.isActiveStatus ->
|
||||
PolicyVerdict(true, "a work interval is running")
|
||||
|
||||
current != null && !current.isWork && rules.blockDuringBreaks &&
|
||||
current.isActiveStatus ->
|
||||
PolicyVerdict(true, "blocking stays on through breaks")
|
||||
|
||||
else -> PolicyVerdict(false, "no work interval is running")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience for callers that only have an IANA zone id string. */
|
||||
fun zoneOrDefault(timezone: String?): ZoneId =
|
||||
runCatching { ZoneId.of(timezone?.trim().orEmpty()) }.getOrElse { ZoneId.systemDefault() }
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Formatting shared by every screen, so numbers read the same everywhere.
|
||||
*
|
||||
* A literal port of `frontend/src/lib/format.ts`, down to the rounding. The web app,
|
||||
* the QML `Theme.humanMinutes()` and this file have to agree: the same pomodoro seen
|
||||
* in a browser, on the desktop and on the phone must not render three different ways.
|
||||
* `FormatTest` asserts the exact outputs the TypeScript produces.
|
||||
*/
|
||||
package tech.kadence.core
|
||||
|
||||
import java.time.LocalDate
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** `"18:24"`, and `"1:05:00"` only once there is an hour to show. */
|
||||
fun clock(totalSeconds: Int): String {
|
||||
val safe = totalSeconds.coerceAtLeast(0)
|
||||
val hours = safe / 3600
|
||||
val minutes = (safe % 3600) / 60
|
||||
val seconds = safe % 60
|
||||
return if (hours > 0) {
|
||||
"$hours:${pad(minutes)}:${pad(seconds)}"
|
||||
} else {
|
||||
"${pad(minutes)}:${pad(seconds)}"
|
||||
}
|
||||
}
|
||||
|
||||
/** The web app's `clock()` takes a float and rounds; a drifting tick can land here. */
|
||||
fun clock(totalSeconds: Double): String = clock(totalSeconds.roundToInt())
|
||||
|
||||
/**
|
||||
* `125` -> `"2h 5m"`, `120` -> `"2h"`, `5` -> `"5m"`.
|
||||
*
|
||||
* Negative input is clamped to zero rather than reproducing JavaScript's `"-1h -5m"`:
|
||||
* a negative duration is a bug upstream, and printing it as nonsense hides it.
|
||||
*/
|
||||
fun humanMinutes(minutes: Int): String {
|
||||
val rounded = minutes.coerceAtLeast(0)
|
||||
val hours = rounded / 60
|
||||
val rest = rounded % 60
|
||||
return when {
|
||||
hours > 0 && rest > 0 -> "${hours}h ${rest}m"
|
||||
hours > 0 -> "${hours}h"
|
||||
else -> "${rest}m"
|
||||
}
|
||||
}
|
||||
|
||||
fun humanMinutes(minutes: Double): String = humanMinutes(minutes.roundToInt())
|
||||
|
||||
/** Interval kind -> the label every client shows. */
|
||||
val KIND_LABEL: Map<String, String> = mapOf(
|
||||
"work" to "Focus",
|
||||
"short_break" to "Short break",
|
||||
"long_break" to "Long break",
|
||||
)
|
||||
|
||||
/**
|
||||
* Task bucket -> heading.
|
||||
*
|
||||
* Note `active`: the web app says "Would be nice to start" where the QML desktop says
|
||||
* "In progress". The web app is the design reference for this client, so the web
|
||||
* wording wins — see `frontend/src/lib/format.ts`.
|
||||
*/
|
||||
val BUCKET_LABEL: Map<String, String> = mapOf(
|
||||
"overdue" to "Overdue",
|
||||
"today" to "Due today",
|
||||
"active" to "Would be nice to start",
|
||||
"upcoming" to "Coming up",
|
||||
"someday" to "No deadline",
|
||||
)
|
||||
|
||||
/** The order the buckets are laid out in, top to bottom. */
|
||||
val BUCKET_ORDER: List<String> = listOf("overdue", "today", "active", "upcoming", "someday")
|
||||
|
||||
/** `""` when undated, `"3d overdue"` when late, otherwise the ISO due date. */
|
||||
fun dueLabel(dueDate: String?, daysOverdue: Int): String = when {
|
||||
dueDate.isNullOrBlank() -> ""
|
||||
daysOverdue > 0 -> "${daysOverdue}d overdue"
|
||||
else -> dueDate
|
||||
}
|
||||
|
||||
/** `"2026-10-19"` -> `"10/19"`, for a chart axis with no room for a year. */
|
||||
fun shortDate(iso: String): String = iso.drop(5).replaceFirst("-", "/")
|
||||
|
||||
/** Monday = 0, matching the backend's `BlockWindow.days` and the heatmap's columns. */
|
||||
fun weekdayOf(iso: String): Int = LocalDate.parse(iso).dayOfWeek.value - 1
|
||||
|
||||
private fun pad(value: Int): String = value.toString().padStart(2, '0')
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Python's `fnmatch`, in Kotlin.
|
||||
*
|
||||
* The block lists are shared across clients and were written for a Python backend:
|
||||
* `steam*`, `*discord*`, `*.youtube.com`. Both the server
|
||||
* (`backend/app/services/blocker_service.py`) and the desktop enforcer
|
||||
* (`desktop/kadence_desktop/blocking/processes.py`) match them with `fnmatch`, so the
|
||||
* phone has to use the same grammar or the same list would mean two different things.
|
||||
*
|
||||
* `fnmatch` is not shell globbing: `*` matches *everything*, separators included, which
|
||||
* is exactly why `*gram*` catches `instagram.com`.
|
||||
*/
|
||||
package tech.kadence.core
|
||||
|
||||
/** The wildcard characters whose presence makes an entry a mask rather than a literal. */
|
||||
private const val WILDCARDS = "*?["
|
||||
|
||||
/** Whether this entry is a mask at all — the branch `processes.matches()` takes first. */
|
||||
fun isMask(pattern: String): Boolean = pattern.any { it in WILDCARDS }
|
||||
|
||||
/**
|
||||
* Translate an `fnmatch` pattern to a `Regex` anchored at both ends.
|
||||
*
|
||||
* Compiled patterns are cached: the app matcher runs this against every block-list
|
||||
* entry several times a second while a focus interval is live.
|
||||
*/
|
||||
fun globToRegex(pattern: String): Regex = globCache.getOrPut(pattern) {
|
||||
val out = StringBuilder()
|
||||
var i = 0
|
||||
while (i < pattern.length) {
|
||||
when (val c = pattern[i]) {
|
||||
'*' -> {
|
||||
out.append(".*")
|
||||
i++
|
||||
}
|
||||
'?' -> {
|
||||
out.append('.')
|
||||
i++
|
||||
}
|
||||
'[' -> {
|
||||
// Find the closing bracket. `]` immediately after `[` or `[!` is a
|
||||
// literal, which is why the scan starts past it.
|
||||
var j = i + 1
|
||||
if (j < pattern.length && (pattern[j] == '!' || pattern[j] == '^')) j++
|
||||
if (j < pattern.length && pattern[j] == ']') j++
|
||||
while (j < pattern.length && pattern[j] != ']') j++
|
||||
if (j >= pattern.length) {
|
||||
// Unterminated class: `fnmatch` treats the `[` as a literal.
|
||||
out.append("\\[")
|
||||
i++
|
||||
} else {
|
||||
val body = pattern.substring(i + 1, j)
|
||||
out.append('[')
|
||||
when {
|
||||
body.startsWith('!') -> out.append('^').append(quoteClass(body.drop(1)))
|
||||
body.startsWith('^') -> out.append("\\^").append(quoteClass(body.drop(1)))
|
||||
else -> out.append(quoteClass(body))
|
||||
}
|
||||
out.append(']')
|
||||
i = j + 1
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
out.append(Regex.escape(c.toString()))
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
Regex(out.toString(), RegexOption.DOT_MATCHES_ALL)
|
||||
}
|
||||
|
||||
/** `fnmatch(text, pattern)` — case-sensitive, like Python's on Linux. */
|
||||
fun globMatches(pattern: String, text: String): Boolean =
|
||||
globToRegex(pattern).matches(text)
|
||||
|
||||
/**
|
||||
* Inside a character class only `]`, `\` and `^` need care; `*` and `?` are literal
|
||||
* there, so they must not be escaped into something a regex reads differently.
|
||||
*/
|
||||
private fun quoteClass(body: String): String = buildString {
|
||||
for (c in body) {
|
||||
when (c) {
|
||||
'\\', ']', '^', '[', '&' -> append('\\').append(c)
|
||||
else -> append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val globCache = HashMap<String, Regex>()
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* What to light up on the Nothing Phone (3)'s rear Glyph Matrix.
|
||||
*
|
||||
* Deliberately pure: the SDK wants a flat array of per-dot brightness, so all the
|
||||
* interesting work — which dots make a "4", where the ring's ticks fall, how it drains
|
||||
* — is arithmetic that needs no device, no `.aar` and no Android at all. The whole rear
|
||||
* display can therefore be developed and regression-tested on a laptop, with
|
||||
* [asciiOf] rendering any frame as art you can read in a test failure.
|
||||
*
|
||||
* The matrix is 25x25 = 625 dots on Phone (3) (`Glyph.DEVICE_23112`), but the length is
|
||||
* read from `Common.getDeviceMatrixLength()` at runtime, so nothing here hard-codes 25
|
||||
* beyond a default.
|
||||
*/
|
||||
package tech.kadence.core
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
|
||||
/** A mutable grid of dot brightness, 0..255. */
|
||||
class GlyphFrame(val size: Int = DEFAULT_SIZE) {
|
||||
val dots = IntArray(size * size)
|
||||
|
||||
fun set(x: Int, y: Int, brightness: Int) {
|
||||
if (x < 0 || y < 0 || x >= size || y >= size) return
|
||||
dots[y * size + x] = brightness.coerceIn(0, 255)
|
||||
}
|
||||
|
||||
fun get(x: Int, y: Int): Int =
|
||||
if (x < 0 || y < 0 || x >= size || y >= size) 0 else dots[y * size + x]
|
||||
|
||||
/** The flat array `GlyphMatrixManager.setMatrixFrame` expects. */
|
||||
fun render(): IntArray = dots.copyOf()
|
||||
|
||||
val litCount: Int get() = dots.count { it > 0 }
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_SIZE = 25
|
||||
}
|
||||
}
|
||||
|
||||
/** Brightness levels, kept together so "paused looks dimmer" is one decision. */
|
||||
object GlyphBrightness {
|
||||
const val FULL = 255
|
||||
const val DIM = 90
|
||||
const val FAINT = 40
|
||||
}
|
||||
|
||||
/**
|
||||
* A 3x5 dot font. Small enough that two digits fit beside each other inside the ring,
|
||||
* large enough to read at arm's length on a desk.
|
||||
*/
|
||||
private val DIGITS: Map<Char, Array<String>> = mapOf(
|
||||
'0' to arrayOf("###", "# #", "# #", "# #", "###"),
|
||||
'1' to arrayOf(" #", " #", " #", " #", " #"),
|
||||
'2' to arrayOf("###", " #", "###", "# ", "###"),
|
||||
'3' to arrayOf("###", " #", "###", " #", "###"),
|
||||
'4' to arrayOf("# #", "# #", "###", " #", " #"),
|
||||
'5' to arrayOf("###", "# ", "###", " #", "###"),
|
||||
'6' to arrayOf("###", "# ", "###", "# #", "###"),
|
||||
'7' to arrayOf("###", " #", " #", " #", " #"),
|
||||
'8' to arrayOf("###", "# #", "###", "# #", "###"),
|
||||
'9' to arrayOf("###", "# #", "###", " #", "###"),
|
||||
)
|
||||
|
||||
private const val GLYPH_W = 3
|
||||
private const val GLYPH_H = 5
|
||||
private const val GLYPH_GAP = 1
|
||||
|
||||
/** Draw a run of digits with its top-left corner at ([x], [y]). */
|
||||
fun GlyphFrame.drawDigits(text: String, x: Int, y: Int, brightness: Int) {
|
||||
var cursor = x
|
||||
for (ch in text) {
|
||||
val glyph = DIGITS[ch]
|
||||
if (glyph == null) {
|
||||
cursor += GLYPH_W + GLYPH_GAP
|
||||
continue
|
||||
}
|
||||
for (row in 0 until GLYPH_H) {
|
||||
for (col in 0 until GLYPH_W) {
|
||||
if (glyph[row][col] == '#') set(cursor + col, y + row, brightness)
|
||||
}
|
||||
}
|
||||
cursor += GLYPH_W + GLYPH_GAP
|
||||
}
|
||||
}
|
||||
|
||||
/** Width in dots of [text] in the 3x5 font. */
|
||||
fun digitsWidth(text: String): Int =
|
||||
if (text.isEmpty()) 0 else text.length * GLYPH_W + (text.length - 1) * GLYPH_GAP
|
||||
|
||||
/** Draw digits centred horizontally. */
|
||||
fun GlyphFrame.drawDigitsCentred(text: String, y: Int, brightness: Int) {
|
||||
drawDigits(text, (size - digitsWidth(text)) / 2, y, brightness)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring: one dot per dial tick around the perimeter, lit for the time **remaining**.
|
||||
*
|
||||
* Tick 0 sits at twelve o'clock and they run clockwise, and the lit run starts there —
|
||||
* so the arc shrinks *towards* twelve as the interval drains, which is how the web
|
||||
* dial's arc reads (`stroke-dashoffset` from `-90deg`). Lighting the trailing ticks
|
||||
* instead would leave the last sliver stranded at eleven o'clock.
|
||||
*
|
||||
* The tick count comes from [dialTickCount], shared with the Compose dial, so the front
|
||||
* and the back of the phone can never disagree about what one dot is worth.
|
||||
*/
|
||||
fun GlyphFrame.drawRing(
|
||||
plannedSeconds: Int,
|
||||
remainingSeconds: Int,
|
||||
brightness: Int = GlyphBrightness.FULL,
|
||||
spentBrightness: Int = 0,
|
||||
) {
|
||||
val count = dialTickCount(plannedSeconds)
|
||||
val remainingTicks = count - dialSpentTicks(plannedSeconds, remainingSeconds)
|
||||
val centre = (size - 1) / 2.0
|
||||
val radius = centre - 1.0
|
||||
|
||||
for (i in 0 until count) {
|
||||
val angle = i.toDouble() / count * 2 * PI - PI / 2
|
||||
val x = (centre + radius * cos(angle)).roundToInt()
|
||||
val y = (centre + radius * sin(angle)).roundToInt()
|
||||
set(x, y, if (i < remainingTicks) brightness else spentBrightness)
|
||||
}
|
||||
}
|
||||
|
||||
/** How the toy should look right now. */
|
||||
object GlyphRenderer {
|
||||
|
||||
/**
|
||||
* The timer face: minutes above seconds, inside a draining ring.
|
||||
*
|
||||
* Past an hour the top row becomes hours and the bottom minutes, because `mm` would
|
||||
* otherwise read as a number far larger than the matrix can show.
|
||||
*/
|
||||
fun timerFrame(
|
||||
remainingSeconds: Int,
|
||||
plannedSeconds: Int,
|
||||
paused: Boolean = false,
|
||||
size: Int = GlyphFrame.DEFAULT_SIZE,
|
||||
): GlyphFrame {
|
||||
val frame = GlyphFrame(size)
|
||||
val safe = remainingSeconds.coerceAtLeast(0)
|
||||
val textBrightness = if (paused) GlyphBrightness.DIM else GlyphBrightness.FULL
|
||||
val ringBrightness = if (paused) GlyphBrightness.DIM else GlyphBrightness.FULL
|
||||
|
||||
val top: Int
|
||||
val bottom: Int
|
||||
if (safe >= 3600) {
|
||||
top = safe / 3600
|
||||
bottom = (safe % 3600) / 60
|
||||
} else {
|
||||
top = safe / 60
|
||||
bottom = safe % 60
|
||||
}
|
||||
|
||||
frame.drawRing(plannedSeconds, safe, ringBrightness)
|
||||
frame.drawDigitsCentred(pad2(top), TOP_ROW_Y, textBrightness)
|
||||
frame.drawDigitsCentred(pad2(bottom), BOTTOM_ROW_Y, textBrightness)
|
||||
return frame
|
||||
}
|
||||
|
||||
/**
|
||||
* The always-on face: today's pomodoros against the goal, as a dot grid.
|
||||
*
|
||||
* The same encoding as the web app's dot row (`TimerPage.tsx`) — earned dots solid,
|
||||
* the rest faint — on 625 LEDs.
|
||||
*/
|
||||
fun dotsFrame(
|
||||
done: Int,
|
||||
goal: Int,
|
||||
size: Int = GlyphFrame.DEFAULT_SIZE,
|
||||
): GlyphFrame {
|
||||
val frame = GlyphFrame(size)
|
||||
val total = maxOf(goal, done, 1)
|
||||
val perRow = minOf(DOTS_PER_ROW, total)
|
||||
val rows = (total + perRow - 1) / perRow
|
||||
|
||||
val spacing = 3
|
||||
val gridW = perRow * spacing - (spacing - 1)
|
||||
val gridH = rows * spacing - (spacing - 1)
|
||||
val originX = (size - gridW) / 2
|
||||
val originY = (size - gridH) / 2
|
||||
|
||||
for (i in 0 until total) {
|
||||
val x = originX + (i % perRow) * spacing
|
||||
val y = originY + (i / perRow) * spacing
|
||||
frame.set(x, y, if (i < done) GlyphBrightness.FULL else GlyphBrightness.FAINT)
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
/** Everything off — what `turnOff()` should leave behind. */
|
||||
fun blank(size: Int = GlyphFrame.DEFAULT_SIZE): GlyphFrame = GlyphFrame(size)
|
||||
|
||||
private const val TOP_ROW_Y = 6
|
||||
private const val BOTTOM_ROW_Y = 14
|
||||
private const val DOTS_PER_ROW = 8
|
||||
|
||||
private fun pad2(value: Int): String =
|
||||
if (value in 0..99) value.toString().padStart(2, '0') else value.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a frame as text, so a failing assertion shows the picture.
|
||||
*
|
||||
* `#` is bright, `+` is dim, `.` is dark.
|
||||
*/
|
||||
fun asciiOf(frame: GlyphFrame): String = buildString {
|
||||
for (y in 0 until frame.size) {
|
||||
for (x in 0 until frame.size) {
|
||||
val v = frame.get(x, y)
|
||||
append(
|
||||
when {
|
||||
v >= 200 -> '#'
|
||||
v > 0 -> '+'
|
||||
else -> '.'
|
||||
},
|
||||
)
|
||||
}
|
||||
if (y < frame.size - 1) append('\n')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Deciding whether a hostname or an app is on the list.
|
||||
*
|
||||
* `AppMatcher` is a port of `matches()` in
|
||||
* `desktop/kadence_desktop/blocking/processes.py`, with Android's two haystacks —
|
||||
* the package name and the app's visible label — standing in for the Linux process
|
||||
* name and its command line.
|
||||
*
|
||||
* `DomainMatcher` goes one step further than the desktop *can* go. A hosts file has no
|
||||
* wildcards, so the backend has to expand every mask into concrete hostnames and
|
||||
* reports whatever it cannot express as `unexpandable`. A URL-bar matcher has no such
|
||||
* limit, so patterns like `*gram*` — which the desktop apologises for — are enforced
|
||||
* here. That is the one place the phone is strictly stronger than the laptop.
|
||||
*/
|
||||
package tech.kadence.core
|
||||
|
||||
import java.net.IDN
|
||||
|
||||
/** What to do about a candidate, and why. */
|
||||
sealed interface BlockDecision {
|
||||
data object Allow : BlockDecision
|
||||
|
||||
/**
|
||||
* @param target what gets reported to `POST /api/blocker/events` and grouped by
|
||||
* `by_target` in the report — the hostname for a site, the matched pattern for an
|
||||
* app, matching what the desktop enforcer sends.
|
||||
* @param matchedRule the block-list entry responsible, for the overlay's copy.
|
||||
*/
|
||||
data class Block(
|
||||
val target: String,
|
||||
val matchedRule: String,
|
||||
val action: String,
|
||||
) : BlockDecision
|
||||
}
|
||||
|
||||
/** `BlockEventRequest.action` — the server only fires `notify_on_block` for `killed`. */
|
||||
object BlockAction {
|
||||
/** A site we turned away; the hosts-file equivalent. */
|
||||
const val BLOCKED = "blocked"
|
||||
|
||||
/** An app we actually ejected the user from. The only value that notifies. */
|
||||
const val KILLED = "killed"
|
||||
|
||||
/** Reported but not enforced — what `kill_blocked_apps = false` means. */
|
||||
const val WARNED = "warned"
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce whatever a browser's URL bar exposed to a bare hostname, or null when the text
|
||||
* is not a host at all.
|
||||
*
|
||||
* The URL bar is a lossy source: it may show `youtube.com`, a full URL, a search
|
||||
* phrase, a `chrome://` page, or a placeholder. Anything that is not confidently a
|
||||
* hostname must return null — a false positive here would block an innocent page.
|
||||
*/
|
||||
fun normalizeHost(raw: String?): String? {
|
||||
var text = raw?.trim()?.lowercase().orEmpty()
|
||||
if (text.isEmpty()) return null
|
||||
|
||||
// A scheme we can never block, and the "about:" family.
|
||||
if (text.startsWith("about:") || text.startsWith("chrome:") ||
|
||||
text.startsWith("chrome-native:") || text.startsWith("data:") ||
|
||||
text.startsWith("file:") || text.startsWith("content:")
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Strip the scheme.
|
||||
val schemeAt = text.indexOf("://")
|
||||
if (schemeAt >= 0) text = text.substring(schemeAt + 3)
|
||||
|
||||
// A space means a search phrase, not a host — but only once the scheme is gone,
|
||||
// since "https://example.com /path" pasted with a stray space is still a URL.
|
||||
text = text.trim()
|
||||
if (text.isEmpty()) return null
|
||||
|
||||
// Authority ends at the first `/`, `?` or `#`.
|
||||
text = text.takeWhile { it != '/' && it != '?' && it != '#' }
|
||||
|
||||
// Drop userinfo and the port.
|
||||
val at = text.lastIndexOf('@')
|
||||
if (at >= 0) text = text.substring(at + 1)
|
||||
val colon = text.indexOf(':')
|
||||
if (colon >= 0) text = text.substring(0, colon)
|
||||
|
||||
text = text.trim().trimEnd('.')
|
||||
if (text.isEmpty() || ' ' in text) return null
|
||||
|
||||
// Unicode domains are matched in their ASCII form, which is what a block list holds.
|
||||
//
|
||||
// ALLOW_UNASSIGNED on purpose: strict `toASCII` *throws* on a host built from
|
||||
// unassigned code points (a homoglyph like `аррӏе.com`), and a throw here would
|
||||
// return null, which means "allow". A blocker that silently waves through the one
|
||||
// category of domain designed to look like something else has it backwards.
|
||||
val ascii = runCatching { IDN.toASCII(text, IDN.ALLOW_UNASSIGNED) }
|
||||
.getOrNull()?.lowercase() ?: return null
|
||||
|
||||
return if (HOST_PATTERN.matches(ascii)) ascii else null
|
||||
}
|
||||
|
||||
private val HOST_PATTERN =
|
||||
Regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$")
|
||||
|
||||
/**
|
||||
* Matches hostnames against the policy.
|
||||
*
|
||||
* @param expandedHosts the server's `all_domains` when online (already expanded and
|
||||
* allow-list-subtracted), or [expandDomains]'s own output when offline.
|
||||
*/
|
||||
class DomainMatcher(
|
||||
private val rules: BlockerRules,
|
||||
expandedHosts: List<String> = expandDomains(rules),
|
||||
) {
|
||||
private val exact: Set<String> = expandedHosts.map { it.trim().lowercase() }.toSet()
|
||||
private val allowMasks: List<String> = allowMasksOf(rules)
|
||||
private val rawEntries: List<String> = rules.blockedDomains
|
||||
.filter { it.isNotBlank() }
|
||||
.map { it.trim().lowercase().trimStart('.') }
|
||||
|
||||
/** host -> the block-list entry that expanded into it; see [expandDomainsWithOrigin]. */
|
||||
private val origin: Map<String, String> = expandDomainsWithOrigin(rules)
|
||||
|
||||
/** @param candidate anything the URL bar gave us — a host, a URL, or junk. */
|
||||
fun decide(candidate: String?): BlockDecision {
|
||||
val host = normalizeHost(candidate) ?: return BlockDecision.Allow
|
||||
|
||||
// 1. The allow list wins outright: `*.google.com` blocked plus
|
||||
// `*.docs.google.com` allowed does what you would expect.
|
||||
if (isAllowed(host, allowMasks)) return BlockDecision.Allow
|
||||
|
||||
// 2. The expanded set — exactly what a hosts file would have caught. Name the
|
||||
// entry that produced the host, falling back to a pattern that matches it
|
||||
// and finally to the host itself (the server-supplied-list case, where we
|
||||
// have no provenance).
|
||||
if (host in exact) {
|
||||
val rule = origin[host] ?: rawEntries.firstOrNull { entryCovers(it, host) } ?: host
|
||||
return BlockDecision.Block(host, rule, BlockAction.BLOCKED)
|
||||
}
|
||||
|
||||
// 3. The raw entries, as patterns. This is the step the desktop cannot do.
|
||||
for (entry in rawEntries) {
|
||||
if (entryCovers(entry, host)) {
|
||||
return BlockDecision.Block(host, entry, BlockAction.BLOCKED)
|
||||
}
|
||||
}
|
||||
|
||||
return BlockDecision.Allow
|
||||
}
|
||||
|
||||
/**
|
||||
* A mask is glob-matched; a plain entry covers itself and any subdomain of it, so
|
||||
* `youtube.com` catches `music.youtube.com` even though no expansion listed it.
|
||||
*/
|
||||
private fun entryCovers(entry: String, host: String): Boolean = when {
|
||||
entry.isEmpty() -> false
|
||||
isMask(entry) -> globMatches(entry, host)
|
||||
else -> host == entry || host.endsWith(".$entry")
|
||||
}
|
||||
|
||||
/** The masks the server had to give up on, which this client does enforce. */
|
||||
fun enforcedBeyondHostsFile(): List<String> = unexpandableMasks(rules)
|
||||
}
|
||||
|
||||
/** Matches installed apps against `blocker.blocked_apps`. */
|
||||
class AppMatcher(private val patterns: List<String>) {
|
||||
|
||||
/**
|
||||
* @param packageName e.g. `com.instagram.android`
|
||||
* @param label the user-visible app name, e.g. `Instagram`
|
||||
* @return the pattern that matched, or null
|
||||
*/
|
||||
fun match(packageName: String, label: String = ""): String? {
|
||||
val name = packageName.trim().lowercase()
|
||||
val alt = label.trim().lowercase()
|
||||
return patterns.firstOrNull { raw -> matches(raw, name, alt) }
|
||||
}
|
||||
|
||||
fun decide(packageName: String, label: String = "", kill: Boolean): BlockDecision {
|
||||
val rule = match(packageName, label) ?: return BlockDecision.Allow
|
||||
val action = if (kill) BlockAction.KILLED else BlockAction.WARNED
|
||||
return BlockDecision.Block(rule, rule, action)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* The port of `processes.matches()`: a mask is glob-matched against either
|
||||
* haystack, while a plain entry is a *substring* test — which is what lets one
|
||||
* shared list (`discord`) work for both a Linux process and `com.discord`.
|
||||
*/
|
||||
fun matches(pattern: String, name: String, alt: String): Boolean {
|
||||
val p = pattern.trim().lowercase()
|
||||
if (p.isEmpty()) return false
|
||||
return if (isMask(p)) {
|
||||
globMatches(p, name) || globMatches(p, alt)
|
||||
} else {
|
||||
p in name || p in alt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* The offline outbox.
|
||||
*
|
||||
* Not one of these endpoints is idempotent: `start` creates, `extend` adds, `interrupt`
|
||||
* increments. So replay is **at-most-once with a post-condition probe**, never blind
|
||||
* retry — if a send fails ambiguously (the socket died after the request went out, so
|
||||
* we cannot know whether the server applied it), we re-read the state and ask "does the
|
||||
* world look like my call landed?" instead of sending it again.
|
||||
*
|
||||
* Coalescing happens at enqueue, not at replay: the queue stays a faithful, minimal
|
||||
* description of what the user asked for, which is far easier to reason about than a
|
||||
* pile of intents collapsed at the last moment.
|
||||
*/
|
||||
package tech.kadence.core
|
||||
|
||||
/** The intents that can be queued. There is no STOP — stopping is [ABANDON]. */
|
||||
enum class OutboxOp {
|
||||
START,
|
||||
PAUSE,
|
||||
RESUME,
|
||||
COMPLETE,
|
||||
ABANDON,
|
||||
EXTEND,
|
||||
INTERRUPT,
|
||||
ATTACH_TASK,
|
||||
}
|
||||
|
||||
/**
|
||||
* One queued intent.
|
||||
*
|
||||
* @param anchorSessionId the interval this intent targeted; null for [OutboxOp.START],
|
||||
* which by definition has no interval yet.
|
||||
* @param repeat how many times to replay it — only [OutboxOp.INTERRUPT] coalesces this
|
||||
* way, because the server increments a counter and the count *is* the data.
|
||||
*/
|
||||
data class OutboxEntry(
|
||||
val seq: Long,
|
||||
val op: OutboxOp,
|
||||
val anchorSessionId: Long? = null,
|
||||
val kind: String? = null,
|
||||
val minutes: Int = 0,
|
||||
val taskId: Long? = null,
|
||||
val reason: String? = null,
|
||||
val repeat: Int = 1,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fold [incoming] into [queue], returning the new queue.
|
||||
*
|
||||
* Every rule here exists because replaying the naive sequence would be wrong or
|
||||
* wasteful, and each one is pinned by a test in `OutboxTest`.
|
||||
*/
|
||||
fun coalesce(queue: List<OutboxEntry>, incoming: OutboxEntry): List<OutboxEntry> {
|
||||
val out = queue.toMutableList()
|
||||
|
||||
when (incoming.op) {
|
||||
// A start supersedes its predecessor on the server anyway (`pomodoro.start()`
|
||||
// abandons the running interval as "superseded"), so every intent aimed at that
|
||||
// interval is moot. Task and settings intents are not timer intents and stay.
|
||||
OutboxOp.START -> {
|
||||
out.removeAll { it.anchorSessionId != null && it.anchorSessionId == incoming.anchorSessionId }
|
||||
out.removeAll { it.op == OutboxOp.START }
|
||||
out.add(incoming)
|
||||
}
|
||||
|
||||
// Pause/resume annihilate: the user changed their mind before we had signal.
|
||||
OutboxOp.RESUME -> {
|
||||
val last = out.lastOrNull { it.isTimerOpFor(incoming.anchorSessionId) }
|
||||
if (last?.op == OutboxOp.PAUSE) out.remove(last) else out.add(incoming)
|
||||
}
|
||||
|
||||
OutboxOp.PAUSE -> {
|
||||
val last = out.lastOrNull { it.isTimerOpFor(incoming.anchorSessionId) }
|
||||
if (last?.op == OutboxOp.RESUME) out.remove(last) else out.add(incoming)
|
||||
}
|
||||
|
||||
// "+5 min" twice is "+10 min" once.
|
||||
OutboxOp.EXTEND -> {
|
||||
val existing = out.lastOrNull {
|
||||
it.op == OutboxOp.EXTEND && it.anchorSessionId == incoming.anchorSessionId
|
||||
}
|
||||
if (existing != null) {
|
||||
out[out.indexOf(existing)] =
|
||||
existing.copy(minutes = existing.minutes + incoming.minutes)
|
||||
} else {
|
||||
out.add(incoming)
|
||||
}
|
||||
}
|
||||
|
||||
// Counted, not collapsed — three interruptions must become three increments.
|
||||
OutboxOp.INTERRUPT -> {
|
||||
val existing = out.lastOrNull {
|
||||
it.op == OutboxOp.INTERRUPT && it.anchorSessionId == incoming.anchorSessionId
|
||||
}
|
||||
if (existing != null) {
|
||||
out[out.indexOf(existing)] = existing.copy(repeat = existing.repeat + incoming.repeat)
|
||||
} else {
|
||||
out.add(incoming)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the final choice of task matters.
|
||||
OutboxOp.ATTACH_TASK -> {
|
||||
out.removeAll {
|
||||
it.op == OutboxOp.ATTACH_TASK && it.anchorSessionId == incoming.anchorSessionId
|
||||
}
|
||||
out.add(incoming)
|
||||
}
|
||||
|
||||
// A terminal intent ends the interval: anything still queued for it would be
|
||||
// rejected with "No interval is running."
|
||||
OutboxOp.COMPLETE, OutboxOp.ABANDON -> {
|
||||
out.removeAll { it.isTimerOpFor(incoming.anchorSessionId) }
|
||||
out.add(incoming)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private fun OutboxEntry.isTimerOpFor(sessionId: Long?): Boolean =
|
||||
sessionId != null && anchorSessionId == sessionId && op != OutboxOp.START
|
||||
|
||||
/** Whether the queue holds anything the reconciler should treat as our own doing. */
|
||||
fun pendingOpsOf(queue: List<OutboxEntry>): PendingOps = PendingOps(
|
||||
hasAny = queue.isNotEmpty(),
|
||||
hasStart = queue.any { it.op == OutboxOp.START },
|
||||
hasTerminal = queue.any { it.op == OutboxOp.COMPLETE || it.op == OutboxOp.ABANDON },
|
||||
)
|
||||
|
||||
/** What to do with a queued entry after an attempt. */
|
||||
sealed interface ReplayOutcome {
|
||||
/** It landed. Drop the row. */
|
||||
data object Done : ReplayOutcome
|
||||
|
||||
/** The server rejected it on its merits. Drop the row; never retry a 4xx. */
|
||||
data class Rejected(val detail: String) : ReplayOutcome
|
||||
|
||||
/** Try again later, with backoff. */
|
||||
data class Retry(val attempt: Int, val delaySeconds: Int) : ReplayOutcome
|
||||
|
||||
/** Out of attempts — surface it and let the user retry or discard. */
|
||||
data object GiveUp : ReplayOutcome
|
||||
}
|
||||
|
||||
/** Eight attempts, 1s doubling to a 60s ceiling. */
|
||||
fun backoffSeconds(attempt: Int): Int =
|
||||
if (attempt <= 0) 1 else (1 shl (attempt - 1).coerceAtMost(6)).coerceAtMost(60)
|
||||
|
||||
const val MAX_REPLAY_ATTEMPTS: Int = 8
|
||||
|
||||
fun replayOutcomeFor(attempt: Int): ReplayOutcome =
|
||||
if (attempt >= MAX_REPLAY_ATTEMPTS) {
|
||||
ReplayOutcome.GiveUp
|
||||
} else {
|
||||
ReplayOutcome.Retry(attempt + 1, backoffSeconds(attempt + 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Did an ambiguously-failed send actually land?
|
||||
*
|
||||
* Compared against the state read back *after* the failure. Each probe is the weakest
|
||||
* claim that is still conclusive — `>=` rather than `==`, because another client may
|
||||
* have moved the same number further along in the meantime.
|
||||
*/
|
||||
fun postconditionHolds(
|
||||
entry: OutboxEntry,
|
||||
before: TimerSessionFacts?,
|
||||
after: ServerTimerState,
|
||||
): Boolean = when (entry.op) {
|
||||
OutboxOp.START ->
|
||||
after.current != null &&
|
||||
after.current.status == Status.RUNNING &&
|
||||
(entry.kind == null || after.current.kind == entry.kind) &&
|
||||
after.current.id != entry.anchorSessionId
|
||||
|
||||
OutboxOp.EXTEND -> {
|
||||
val base = before?.plannedSeconds ?: return false
|
||||
val now = after.current ?: return false
|
||||
now.id == entry.anchorSessionId && now.plannedSeconds >= base + entry.minutes * 60
|
||||
}
|
||||
|
||||
OutboxOp.INTERRUPT -> {
|
||||
val base = before?.interruptions ?: return false
|
||||
val now = after.current ?: return false
|
||||
now.id == entry.anchorSessionId && now.interruptions >= base + entry.repeat
|
||||
}
|
||||
|
||||
OutboxOp.PAUSE -> after.current?.takeIf { it.id == entry.anchorSessionId }?.isPaused == true
|
||||
|
||||
OutboxOp.RESUME ->
|
||||
after.current?.takeIf { it.id == entry.anchorSessionId }?.status == Status.RUNNING
|
||||
|
||||
// The interval is gone, or has been replaced by a newer one.
|
||||
OutboxOp.COMPLETE, OutboxOp.ABANDON ->
|
||||
after.current == null || after.current.id != entry.anchorSessionId
|
||||
|
||||
OutboxOp.ATTACH_TASK -> false // Nothing observable to probe; let it retry.
|
||||
}
|
||||
|
||||
/**
|
||||
* An interval that both began *and* ended while offline.
|
||||
*
|
||||
* It cannot be filed honestly: `pomodoro.start()` stamps `started_at = utcnow()` and
|
||||
* `focus_minutes` is derived from `actual_seconds`, so replaying start-then-complete on
|
||||
* reconnect would record a pomodoro at the wrong hour with roughly zero duration,
|
||||
* quietly corrupting the very statistics the app exists to produce. These are
|
||||
* quarantined and surfaced, never replayed silently.
|
||||
*/
|
||||
data class UnfiledInterval(
|
||||
val kind: String,
|
||||
val startedAtWallMillis: Long,
|
||||
val endedAtWallMillis: Long,
|
||||
val plannedSeconds: Int,
|
||||
val interruptions: Int = 0,
|
||||
) {
|
||||
val actualSeconds: Int get() = ((endedAtWallMillis - startedAtWallMillis) / 1000L).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a drained queue into what can be replayed and what has to be quarantined.
|
||||
*
|
||||
* A START whose matching COMPLETE/ABANDON is also still queued never reached the
|
||||
* server, so the whole interval happened offline — see [UnfiledInterval].
|
||||
*/
|
||||
fun partitionForReplay(queue: List<OutboxEntry>): Pair<List<OutboxEntry>, List<OutboxEntry>> {
|
||||
val startSeqs = queue.filter { it.op == OutboxOp.START }.map { it.seq }.toSet()
|
||||
if (startSeqs.isEmpty()) return queue to emptyList()
|
||||
|
||||
// A terminal op with no anchor belongs to the queued start that precedes it.
|
||||
val quarantined = mutableSetOf<Long>()
|
||||
for (start in queue.filter { it.op == OutboxOp.START }) {
|
||||
val terminal = queue.firstOrNull {
|
||||
it.seq > start.seq && (it.op == OutboxOp.COMPLETE || it.op == OutboxOp.ABANDON)
|
||||
}
|
||||
if (terminal != null) {
|
||||
quarantined += queue.filter { it.seq >= start.seq && it.seq <= terminal.seq }
|
||||
.map { it.seq }
|
||||
}
|
||||
}
|
||||
|
||||
val replayable = queue.filter { it.seq !in quarantined }
|
||||
val unfiled = queue.filter { it.seq in quarantined }
|
||||
return replayable to unfiled
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Clock windows, including the ones that wrap midnight.
|
||||
*
|
||||
* A port of `in_window()` in `backend/app/timeutil.py`, used for the blocker's
|
||||
* `schedule[]` entries and for Telegram's quiet hours. Both ends are inclusive, and a
|
||||
* window whose start is after its end (`23:00 -> 08:00`) spans midnight.
|
||||
*/
|
||||
package tech.kadence.core
|
||||
|
||||
import java.time.LocalTime
|
||||
|
||||
/** Whether `moment` falls inside `[start, end]`, wrapping across midnight if needed. */
|
||||
fun inWindow(moment: LocalTime, start: LocalTime, end: LocalTime): Boolean =
|
||||
if (start <= end) moment >= start && moment <= end else moment >= start || moment <= end
|
||||
|
||||
/**
|
||||
* Parse the `"HH:MM"` the settings document stores.
|
||||
*
|
||||
* Returns null rather than throwing: these strings come from a server document a user
|
||||
* can edit, and one malformed window must not take the whole policy down with it.
|
||||
*/
|
||||
fun parseClockTime(raw: String?): LocalTime? {
|
||||
val text = raw?.trim().orEmpty()
|
||||
if (text.isEmpty()) return null
|
||||
val parts = text.split(':')
|
||||
if (parts.size < 2) return null
|
||||
val hour = parts[0].toIntOrNull() ?: return null
|
||||
val minute = parts[1].toIntOrNull() ?: return null
|
||||
val second = if (parts.size > 2) parts[2].toIntOrNull() ?: 0 else 0
|
||||
if (hour !in 0..23 || minute !in 0..59 || second !in 0..59) return null
|
||||
return LocalTime.of(hour, minute, second)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* The timer, as a client is allowed to understand it.
|
||||
*
|
||||
* The server owns the clock (`backend/app/services/pomodoro.py`); a client renders
|
||||
* `remaining_seconds` and ticks locally between polls. Everything here is pure: given
|
||||
* what the server last said, what the phone believes, and what is still queued, work
|
||||
* out what to show and whether to say anything about it.
|
||||
*
|
||||
* Three server behaviours this file has to respect:
|
||||
*
|
||||
* * `POST /api/timer/start` **abandons** whatever is running, with reason
|
||||
* `"superseded"` — so a queued start is destructive, not additive.
|
||||
* * `POST /api/timer/complete` may **auto-start the next interval**, so a reply can
|
||||
* contain an interval nobody explicitly asked for.
|
||||
* * There is **no stop endpoint**: stopping is `abandon`, and under `strict_mode` a
|
||||
* work interval refuses to be abandoned without a reason.
|
||||
*/
|
||||
package tech.kadence.core
|
||||
|
||||
/** The interval kinds, as the API spells them. */
|
||||
object Kind {
|
||||
const val WORK = "work"
|
||||
const val SHORT_BREAK = "short_break"
|
||||
const val LONG_BREAK = "long_break"
|
||||
}
|
||||
|
||||
/** The interval statuses, as the API spells them. */
|
||||
object Status {
|
||||
const val RUNNING = "running"
|
||||
const val PAUSED = "paused"
|
||||
const val COMPLETED = "completed"
|
||||
const val ABANDONED = "abandoned"
|
||||
}
|
||||
|
||||
/** The parts of `TimerState.current` a client actually reasons about. */
|
||||
data class TimerSessionFacts(
|
||||
val id: Long,
|
||||
val kind: String,
|
||||
val status: String,
|
||||
val plannedSeconds: Int,
|
||||
val remainingSeconds: Int,
|
||||
val pausedSeconds: Int = 0,
|
||||
val interruptions: Int = 0,
|
||||
) {
|
||||
val isWork: Boolean get() = kind == Kind.WORK
|
||||
val isPaused: Boolean get() = status == Status.PAUSED
|
||||
val isActive: Boolean get() = status == Status.RUNNING || status == Status.PAUSED
|
||||
}
|
||||
|
||||
/** `GET /api/timer/state`, reduced to what drives the UI. */
|
||||
data class ServerTimerState(
|
||||
val current: TimerSessionFacts?,
|
||||
val nextKind: String = Kind.WORK,
|
||||
val completedToday: Int = 0,
|
||||
val goal: Int = 8,
|
||||
val cyclePosition: Int = 0,
|
||||
val cyclesBeforeLongBreak: Int = 4,
|
||||
/** **Minutes**, not seconds — `pomodoro.state()` sends minutes here. */
|
||||
val durationsMinutes: Map<String, Int> = emptyMap(),
|
||||
val timerEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* Where the local tick counts down from.
|
||||
*
|
||||
* Anchored on [elapsedNanos] — a monotonic clock, never wall time — so a timezone
|
||||
* change or an NTP correction mid-interval cannot corrupt a countdown.
|
||||
*/
|
||||
data class TickAnchor(
|
||||
val sessionId: Long,
|
||||
val remainingAtAnchor: Int,
|
||||
val elapsedNanos: Long,
|
||||
val paused: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Seconds left at [nowNanos]. A paused interval is frozen at its anchor.
|
||||
*
|
||||
* The elapsed delta is floored at zero: a monotonic source should never go backwards,
|
||||
* but if it ever appears to, time must not be *added* to the interval — a countdown
|
||||
* that grows is worse than one that stalls.
|
||||
*/
|
||||
fun remainingAt(anchor: TickAnchor, nowNanos: Long): Int {
|
||||
if (anchor.paused) return anchor.remainingAtAnchor.coerceAtLeast(0)
|
||||
val elapsedSeconds = ((nowNanos - anchor.elapsedNanos) / 1_000_000_000L)
|
||||
.coerceAtLeast(0L)
|
||||
.toInt()
|
||||
return (anchor.remainingAtAnchor - elapsedSeconds).coerceIn(0, anchor.remainingAtAnchor)
|
||||
}
|
||||
|
||||
/** `1 - remaining/planned`, clamped — the dial's fill, mirroring `controller.progress`. */
|
||||
fun progressOf(remainingSeconds: Int, plannedSeconds: Int): Float {
|
||||
if (plannedSeconds <= 0) return 0f
|
||||
val raw = 1f - remainingSeconds.toFloat() / plannedSeconds.toFloat()
|
||||
return raw.coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many ticks the dial draws, and how many are spent.
|
||||
*
|
||||
* One per minute up to an hour, then one per five minutes — the rule shared by
|
||||
* `frontend/src/components/TimerDial.tsx` and `qml/components/TimerDial.qml`, kept here
|
||||
* so the Compose dial and the Glyph ring cannot drift apart.
|
||||
*/
|
||||
fun dialTickCount(plannedSeconds: Int): Int {
|
||||
val minutes = Math.round(plannedSeconds / 60.0).toInt().coerceAtLeast(1)
|
||||
return if (minutes <= 60) minutes else Math.round(minutes / 5.0).toInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
fun dialSpentTicks(plannedSeconds: Int, remainingSeconds: Int): Int {
|
||||
val count = dialTickCount(plannedSeconds)
|
||||
val elapsed = progressOf(remainingSeconds, plannedSeconds)
|
||||
return kotlin.math.floor(count * elapsed).toInt().coerceIn(0, count)
|
||||
}
|
||||
|
||||
/**
|
||||
* What should run next, worked out locally for an offline start.
|
||||
*
|
||||
* A port of `pomodoro.next_kind()`: after every `cyclesBeforeLongBreak` completed work
|
||||
* intervals today the next break is the long one, and the count resets with the day, so
|
||||
* a long break is never owed from yesterday. Online, the server's `next_kind` wins.
|
||||
*/
|
||||
fun nextKindLocally(
|
||||
lastKind: String?,
|
||||
lastStatus: String?,
|
||||
completedWorkToday: Int,
|
||||
cyclesBeforeLongBreak: Int,
|
||||
): String {
|
||||
if (lastKind == null || lastKind != Kind.WORK || lastStatus == Status.ABANDONED) {
|
||||
return Kind.WORK
|
||||
}
|
||||
val cycles = cyclesBeforeLongBreak.coerceAtLeast(1)
|
||||
return if (completedWorkToday > 0 && completedWorkToday % cycles == 0) {
|
||||
Kind.LONG_BREAK
|
||||
} else {
|
||||
Kind.SHORT_BREAK
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `abandon` will be refused for want of a reason.
|
||||
*
|
||||
* `pomodoro.abandon()` raises under `strict_mode` for a **work** interval with a blank
|
||||
* reason. Checking it client-side is what lets the offline "stop" affordance collect
|
||||
* the reason up front instead of queueing a call that is certain to 400.
|
||||
*/
|
||||
fun abandonNeedsReason(strictMode: Boolean, kind: String, reason: String?): Boolean =
|
||||
strictMode && kind == Kind.WORK && reason.isNullOrBlank()
|
||||
|
||||
/** Six hours, matching `pomodoro.reap_stale()`'s auto-abandon threshold. */
|
||||
const val STALE_INTERVAL_SECONDS: Int = 6 * 60 * 60
|
||||
|
||||
/**
|
||||
* The client-side mirror of `reap_stale`: stop believing in a local interval that has
|
||||
* been ticking for six hours with no server contact, so a phone left offline overnight
|
||||
* does not claim a fourteen-hour pomodoro.
|
||||
*/
|
||||
fun isLocallyStale(elapsedSeconds: Int): Boolean = elapsedSeconds > STALE_INTERVAL_SECONDS
|
||||
|
||||
// --- reconciliation ---------------------------------------------------------
|
||||
|
||||
/** What the phone currently believes it is showing. */
|
||||
data class LocalBelief(
|
||||
val sessionId: Long?,
|
||||
val paused: Boolean = false,
|
||||
)
|
||||
|
||||
/** What is still queued, which decides whether a difference is ours or somebody's. */
|
||||
data class PendingOps(
|
||||
val hasAny: Boolean = false,
|
||||
val hasStart: Boolean = false,
|
||||
val hasTerminal: Boolean = false,
|
||||
)
|
||||
|
||||
/** The outcome of comparing the server's answer with what the phone was showing. */
|
||||
sealed interface Reconciliation {
|
||||
/** Re-anchor to the server and say nothing. The overwhelmingly common case. */
|
||||
data object Adopt : Reconciliation
|
||||
|
||||
/** Keep the optimistic local value: re-anchoring now would visibly bounce. */
|
||||
data object KeepOptimistic : Reconciliation
|
||||
|
||||
/** Our own queued intent landed; adopt it without narrating it. */
|
||||
data object AdoptOwnReplay : Reconciliation
|
||||
|
||||
/** Another client took over. Worth telling the user. */
|
||||
data class AdoptForeign(val kind: String) : Reconciliation
|
||||
|
||||
/** The interval we were ticking is gone and we did not end it. */
|
||||
data object EndedElsewhere : Reconciliation
|
||||
|
||||
/** Still the same interval, but somebody else paused it. */
|
||||
data object PausedElsewhere : Reconciliation
|
||||
|
||||
/** `timer_enabled` went false: every queued timer intent is now certain to 400. */
|
||||
data object ViewOnlyPurge : Reconciliation
|
||||
|
||||
/** No answer from the server; keep ticking from the anchor. */
|
||||
data object Offline : Reconciliation
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what to do with a poll result.
|
||||
*
|
||||
* @param server the freshly polled state, or null when the backend is unreachable.
|
||||
*/
|
||||
fun reconcile(
|
||||
server: ServerTimerState?,
|
||||
local: LocalBelief,
|
||||
pending: PendingOps = PendingOps(),
|
||||
): Reconciliation {
|
||||
if (server == null) return Reconciliation.Offline
|
||||
|
||||
// A disabled timer refuses start/pause/resume outright, so anything queued is
|
||||
// dead on arrival; dropping it is kinder than retrying it eight times.
|
||||
if (!server.timerEnabled) return Reconciliation.ViewOnlyPurge
|
||||
|
||||
val current = server.current
|
||||
|
||||
if (current == null) {
|
||||
return when {
|
||||
pending.hasTerminal -> Reconciliation.AdoptOwnReplay
|
||||
local.sessionId != null -> Reconciliation.EndedElsewhere
|
||||
else -> Reconciliation.Adopt
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing local to contradict: a cold start, or we were idle and someone began an
|
||||
// interval. Either way there is no conflict to report.
|
||||
if (local.sessionId == null) {
|
||||
return if (pending.hasStart) Reconciliation.AdoptOwnReplay else Reconciliation.Adopt
|
||||
}
|
||||
|
||||
if (local.sessionId != current.id) {
|
||||
return if (pending.hasStart) {
|
||||
Reconciliation.AdoptOwnReplay
|
||||
} else {
|
||||
Reconciliation.AdoptForeign(current.kind)
|
||||
}
|
||||
}
|
||||
|
||||
// Same interval.
|
||||
return when {
|
||||
pending.hasAny -> Reconciliation.KeepOptimistic
|
||||
current.isPaused && !local.paused -> Reconciliation.PausedElsewhere
|
||||
else -> Reconciliation.Adopt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* The design tokens, as ARGB.
|
||||
*
|
||||
* A transcription of `frontend/src/tokens.ts` and `frontend/src/lib/viz.ts`, kept in the
|
||||
* pure module so `TokenParityTest` can read those TypeScript files directly and fail the
|
||||
* build when the two drift. The design brief is that all clients are "the same product";
|
||||
* this is the only way to actually enforce it rather than hope for it.
|
||||
*
|
||||
* Values are `0xAARRGGBB` longs because `androidx.compose.ui.graphics.Color` cannot be
|
||||
* referenced from a module that deliberately has no Android dependency. The app module
|
||||
* wraps each one in `Color(...)` and nothing else.
|
||||
*/
|
||||
package tech.kadence.core
|
||||
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/** `#RRGGBB`, fully opaque. */
|
||||
internal fun rgb(hex: Long): Long = 0xFF000000L or hex
|
||||
|
||||
/** `rgba(r, g, b, a)` with the CSS alpha fraction, rounded half-up to 8 bits. */
|
||||
internal fun rgba(hex: Long, alpha: Double): Long =
|
||||
((alpha * 255.0).roundToLong() shl 24) or hex
|
||||
|
||||
/** The semantic palette. One instance per theme; see [kadenceTokens]. */
|
||||
data class KadenceTokens(
|
||||
val paper: Long,
|
||||
val surface: Long,
|
||||
val surfaceSunken: Long,
|
||||
val ink: Long,
|
||||
val inkMuted: Long,
|
||||
val inkFaint: Long,
|
||||
val rule: Long,
|
||||
val ruleStrong: Long,
|
||||
val ember: Long,
|
||||
val emberSoft: Long,
|
||||
val pine: Long,
|
||||
val amber: Long,
|
||||
val clay: Long,
|
||||
)
|
||||
|
||||
val KADENCE_LIGHT = KadenceTokens(
|
||||
paper = rgb(0xFAF7F2),
|
||||
surface = rgb(0xFFFFFF),
|
||||
surfaceSunken = rgb(0xF3EFE8),
|
||||
ink = rgb(0x17150F),
|
||||
inkMuted = rgb(0x6E675B),
|
||||
inkFaint = rgb(0x9A9388),
|
||||
rule = rgba(0x17150F, 0.12),
|
||||
ruleStrong = rgba(0x17150F, 0.26),
|
||||
ember = rgb(0xB4441E),
|
||||
emberSoft = rgba(0xB4441E, 0.10),
|
||||
pine = rgb(0x2F6B55),
|
||||
amber = rgb(0x8A6200),
|
||||
clay = rgb(0xA33A2E),
|
||||
)
|
||||
|
||||
val KADENCE_DARK = KadenceTokens(
|
||||
paper = rgb(0x121110),
|
||||
surface = rgb(0x1A1917),
|
||||
surfaceSunken = rgb(0x201E1B),
|
||||
ink = rgb(0xF4F1EA),
|
||||
inkMuted = rgb(0xA79F93),
|
||||
inkFaint = rgb(0x776F64),
|
||||
rule = rgba(0xF4F1EA, 0.14),
|
||||
ruleStrong = rgba(0xF4F1EA, 0.30),
|
||||
ember = rgb(0xE0724A),
|
||||
emberSoft = rgba(0xE0724A, 0.14),
|
||||
pine = rgb(0x6FB394),
|
||||
amber = rgb(0xD9A441),
|
||||
clay = rgb(0xE07A6E),
|
||||
)
|
||||
|
||||
fun kadenceTokens(dark: Boolean): KadenceTokens = if (dark) KADENCE_DARK else KADENCE_LIGHT
|
||||
|
||||
/**
|
||||
* Chart colours — deliberately **not** the brand accent, and deliberately **not** the
|
||||
* system accent either. A chart has to stay readable for colour-blind readers in both
|
||||
* themes, which is a property of this validated palette and not of whatever hue the
|
||||
* wallpaper happens to produce.
|
||||
*/
|
||||
data class VizTokens(
|
||||
val work: Long,
|
||||
val shortBreak: Long,
|
||||
val longBreak: Long,
|
||||
val sequential: List<Long>,
|
||||
val grid: Long,
|
||||
val axis: Long,
|
||||
val empty: Long,
|
||||
)
|
||||
|
||||
val VIZ_LIGHT = VizTokens(
|
||||
work = rgb(0xEB6834),
|
||||
shortBreak = rgb(0x2A78D6),
|
||||
longBreak = rgb(0x1BAF7A),
|
||||
// blue 100 -> 600: "more pomodoros" reads as "darker".
|
||||
sequential = listOf(
|
||||
rgb(0xCDE2FB), rgb(0x9EC5F4), rgb(0x6DA7EC),
|
||||
rgb(0x3987E5), rgb(0x256ABF), rgb(0x184F95),
|
||||
),
|
||||
grid = rgba(0x17150F, 0.12),
|
||||
axis = rgba(0x17150F, 0.45),
|
||||
empty = rgba(0x17150F, 0.07),
|
||||
)
|
||||
|
||||
val VIZ_DARK = VizTokens(
|
||||
work = rgb(0xD95926),
|
||||
shortBreak = rgb(0x3987E5),
|
||||
longBreak = rgb(0x199E70),
|
||||
// Reversed, so "more" still reads as "darker" against a dark ground.
|
||||
sequential = listOf(
|
||||
rgb(0x104281), rgb(0x184F95), rgb(0x256ABF),
|
||||
rgb(0x3987E5), rgb(0x6DA7EC), rgb(0x9EC5F4),
|
||||
),
|
||||
grid = rgba(0xF4F1EA, 0.14),
|
||||
axis = rgba(0xF4F1EA, 0.55),
|
||||
empty = rgba(0xF4F1EA, 0.08),
|
||||
)
|
||||
|
||||
fun vizTokens(dark: Boolean): VizTokens = if (dark) VIZ_DARK else VIZ_LIGHT
|
||||
|
||||
/** Port of `kindColor()` in `viz.ts`. */
|
||||
fun kindColor(kind: String, viz: VizTokens): Long = when (kind) {
|
||||
Kind.WORK -> viz.work
|
||||
Kind.LONG_BREAK -> viz.longBreak
|
||||
else -> viz.shortBreak
|
||||
}
|
||||
|
||||
/** Port of `rampStep()` in `viz.ts`. Null means "nothing happened". */
|
||||
fun rampStep(count: Int, goal: Int, viz: VizTokens): Long? {
|
||||
if (count <= 0) return null
|
||||
val steps = viz.sequential.size
|
||||
val ratio = if (goal > 0) count.toDouble() / goal else count.toDouble() / 8.0
|
||||
val index = (ratio * (steps - 1)).roundToLong().toInt().coerceIn(0, steps - 1)
|
||||
return viz.sequential[index]
|
||||
}
|
||||
|
||||
// --- the system accent -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Where the accent comes from.
|
||||
*
|
||||
* The canvas stays Kadence's — warm paper, ink type, hairline rules — while the single
|
||||
* accent is taken from the system's dynamic palette when Android offers one, falling
|
||||
* back to the brand ember. That keeps the editorial design intact (which is the look
|
||||
* that was accepted) while letting the app pick up the phone's own colour.
|
||||
*
|
||||
* Chart colours are explicitly excluded: see [VizTokens].
|
||||
*/
|
||||
fun accentArgb(systemAccent: Long?, dark: Boolean): Long =
|
||||
systemAccent ?: kadenceTokens(dark).ember
|
||||
|
||||
/**
|
||||
* WCAG relative luminance of an opaque colour.
|
||||
*
|
||||
* Alpha is ignored: these are used to choose label colours against a solid fill.
|
||||
*/
|
||||
fun relativeLuminance(argb: Long): Double {
|
||||
fun channel(value: Long): Double {
|
||||
val c = value / 255.0
|
||||
return if (c <= 0.03928) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
val r = channel((argb shr 16) and 0xFF)
|
||||
val g = channel((argb shr 8) and 0xFF)
|
||||
val b = channel(argb and 0xFF)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
/** WCAG contrast ratio, 1.0 (identical) to 21.0 (black on white). */
|
||||
fun contrastRatio(a: Long, b: Long): Double {
|
||||
val la = relativeLuminance(a)
|
||||
val lb = relativeLuminance(b)
|
||||
val lighter = maxOf(la, lb)
|
||||
val darker = minOf(la, lb)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
/**
|
||||
* The label colour to put on top of [background].
|
||||
*
|
||||
* The brand ember is dark enough for white (and `theme.ts` hard-codes exactly that),
|
||||
* but a wallpaper-derived accent can be any lightness at all — Material You will
|
||||
* happily hand back a pale yellow. Picking whichever of ink/paper actually contrasts is
|
||||
* what stops "use the system colour" from quietly producing unreadable buttons.
|
||||
*/
|
||||
fun onAccentArgb(background: Long): Long {
|
||||
// The same two candidates the other clients use: white, or the near-black ink that
|
||||
// `theme.ts` names as the dark-theme contrast colour.
|
||||
val onLight = rgb(0xFFFFFF)
|
||||
val onDark = rgb(0x16140F)
|
||||
return if (contrastRatio(background, onLight) >= contrastRatio(background, onDark)) {
|
||||
onLight
|
||||
} else {
|
||||
onDark
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a colour clears WCAG AA for large text / UI components (3:1). */
|
||||
fun meetsLargeTextContrast(foreground: Long, background: Long): Boolean =
|
||||
contrastRatio(foreground, background) >= 3.0
|
||||
|
||||
/** Whether a colour clears WCAG AA for body text (4.5:1). */
|
||||
fun meetsBodyContrast(foreground: Long, background: Long): Boolean =
|
||||
contrastRatio(foreground, background) >= 4.5
|
||||
@@ -0,0 +1,243 @@
|
||||
package tech.kadence.core
|
||||
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Parity with `backend/app/services/blocker_service.py`.
|
||||
*
|
||||
* Every expectation in `expands like the backend` was produced by calling the real
|
||||
* `expand_entry()` through the backend's own venv, not derived by reading the code —
|
||||
* the phone enforces this list offline, so a divergence here means the guard behaves
|
||||
* differently depending on whether you have signal.
|
||||
*/
|
||||
class BlockerTest {
|
||||
|
||||
private val subs = BlockerRules.DEFAULT_SUBDOMAINS
|
||||
|
||||
@Test
|
||||
fun `expands like the backend`() {
|
||||
assertEquals(
|
||||
listOf("youtube.com", "www.youtube.com", "m.youtube.com") to null,
|
||||
expandEntry("youtube.com", subs),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"youtube.com", "www.youtube.com", "m.youtube.com", "mobile.youtube.com",
|
||||
"app.youtube.com", "web.youtube.com", "i.youtube.com", "old.youtube.com",
|
||||
"new.youtube.com",
|
||||
) to null,
|
||||
expandEntry("*.youtube.com", subs),
|
||||
)
|
||||
// A leading dot is stripped, and the entry is trimmed and lower-cased.
|
||||
assertEquals(
|
||||
listOf("reddit.com", "www.reddit.com", "m.reddit.com") to null,
|
||||
expandEntry(".reddit.com", subs),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("tiktok.com", "www.tiktok.com", "m.tiktok.com") to null,
|
||||
expandEntry(" TikTok.com ", subs),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `patterns a hosts file cannot express come back as leftovers`() {
|
||||
assertEquals(emptyList<String>() to "*gram*", expandEntry("*gram*", subs))
|
||||
assertEquals(emptyList<String>() to "*.", expandEntry("*.", subs))
|
||||
assertEquals(emptyList<String>() to "**", expandEntry("**", subs))
|
||||
assertEquals(emptyList<String>() to "*.a*.com", expandEntry("*.a*.com", subs))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty entry is neither expanded nor reported`() {
|
||||
assertEquals(emptyList<String>() to null, expandEntry("", subs))
|
||||
assertEquals(emptyList<String>() to null, expandEntry(" ", subs))
|
||||
assertEquals(emptyList<String>() to null, expandEntry(".", subs))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isAllowed matches the backend, mask by mask`() {
|
||||
assertTrue(isAllowed("docs.google.com", listOf("docs.google.com")))
|
||||
assertTrue(isAllowed("www.youtube.com", listOf("*.youtube.com")))
|
||||
// Both of these are false in the backend too: a `*.` mask does not cover the
|
||||
// bare domain, which is why `expand_entry` emits the base name separately.
|
||||
assertFalse(isAllowed("youtube.com", listOf("*.youtube.com")))
|
||||
assertFalse(isAllowed("docs.google.com", listOf("*.docs.google.com")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expandDomains subtracts the allow list`() {
|
||||
val rules = BlockerRules(
|
||||
blockedDomains = listOf("*.youtube.com"),
|
||||
allowedDomains = listOf("music.youtube.com", "*.i.youtube.com"),
|
||||
subdomainExpansions = listOf("www", "m", "music", "i"),
|
||||
)
|
||||
val hosts = expandDomains(rules)
|
||||
assertTrue("youtube.com" in hosts)
|
||||
assertTrue("www.youtube.com" in hosts)
|
||||
assertTrue("m.youtube.com" in hosts)
|
||||
assertFalse("music.youtube.com" in hosts, "an explicitly allowed host is removed")
|
||||
assertTrue("i.youtube.com" in hosts, "the bare i. host is not covered by `*.i.`")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expandDomains de-duplicates overlapping entries`() {
|
||||
val rules = BlockerRules(
|
||||
blockedDomains = listOf("youtube.com", "youtube.com", "*.youtube.com"),
|
||||
subdomainExpansions = listOf("www", "m"),
|
||||
)
|
||||
val hosts = expandDomains(rules)
|
||||
assertEquals(hosts.size, hosts.distinct().size, "no host appears twice")
|
||||
assertEquals(listOf("youtube.com", "www.youtube.com", "m.youtube.com"), hosts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unexpandableMasks collects the leftovers once each`() {
|
||||
val rules = BlockerRules(
|
||||
blockedDomains = listOf("youtube.com", "*gram*", "*gram*", "*tube*"),
|
||||
)
|
||||
assertEquals(listOf("*gram*", "*tube*"), unexpandableMasks(rules))
|
||||
}
|
||||
|
||||
// --- the policy decision -------------------------------------------------
|
||||
|
||||
private val zone = ZoneId.of("Europe/Moscow")
|
||||
private fun at(day: String, time: String): ZonedDateTime =
|
||||
ZonedDateTime.of(java.time.LocalDate.parse(day), java.time.LocalTime.parse(time), zone)
|
||||
|
||||
private val working = IntervalFacts("work", "running")
|
||||
private val breaking = IntervalFacts("short_break", "running")
|
||||
|
||||
@Test
|
||||
fun `disabled beats everything`() {
|
||||
val rules = BlockerRules(enabled = false, mode = "always")
|
||||
val verdict = evaluatePolicy(rules, working, at("2026-10-19", "12:00"))
|
||||
assertFalse(verdict.active)
|
||||
assertEquals("disabled", verdict.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `always on needs no interval`() {
|
||||
val rules = BlockerRules(enabled = true, mode = "always")
|
||||
val verdict = evaluatePolicy(rules, null, at("2026-10-19", "03:00"))
|
||||
assertTrue(verdict.active)
|
||||
assertEquals("always on", verdict.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `during work follows the interval`() {
|
||||
val rules = BlockerRules(enabled = true, mode = "during_work")
|
||||
val now = at("2026-10-19", "12:00")
|
||||
|
||||
assertTrue(evaluatePolicy(rules, working, now).active)
|
||||
assertEquals("a work interval is running", evaluatePolicy(rules, working, now).reason)
|
||||
|
||||
// A paused work interval still blocks — you did not finish, you stepped away.
|
||||
assertTrue(evaluatePolicy(rules, IntervalFacts("work", "paused"), now).active)
|
||||
|
||||
// A finished or abandoned interval does not.
|
||||
assertFalse(evaluatePolicy(rules, IntervalFacts("work", "completed"), now).active)
|
||||
assertFalse(evaluatePolicy(rules, IntervalFacts("work", "abandoned"), now).active)
|
||||
assertFalse(evaluatePolicy(rules, null, now).active)
|
||||
assertEquals("no work interval is running", evaluatePolicy(rules, null, now).reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `breaks block only when asked to`() {
|
||||
val now = at("2026-10-19", "12:00")
|
||||
val off = BlockerRules(enabled = true, mode = "during_work", blockDuringBreaks = false)
|
||||
val on = BlockerRules(enabled = true, mode = "during_work", blockDuringBreaks = true)
|
||||
|
||||
assertFalse(evaluatePolicy(off, breaking, now).active)
|
||||
assertTrue(evaluatePolicy(on, breaking, now).active)
|
||||
assertEquals("blocking stays on through breaks", evaluatePolicy(on, breaking, now).reason)
|
||||
assertTrue(evaluatePolicy(on, IntervalFacts("long_break", "paused"), now).active)
|
||||
// Still nothing to block through when no interval exists at all.
|
||||
assertFalse(evaluatePolicy(on, null, now).active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a schedule window is checked by weekday and clock`() {
|
||||
val rules = BlockerRules(
|
||||
enabled = true,
|
||||
mode = "scheduled",
|
||||
// Monday = 0; this is Mon-Fri, 09:00-18:00.
|
||||
schedule = listOf(BlockWindow("Work hours", "09:00", "18:00", listOf(0, 1, 2, 3, 4))),
|
||||
)
|
||||
// 2026-10-19 is a Monday.
|
||||
assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "09:00")).active)
|
||||
assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "13:37")).active)
|
||||
assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "18:00")).active)
|
||||
assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "08:59")).active)
|
||||
assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "18:01")).active)
|
||||
// Saturday is not in the list.
|
||||
assertFalse(evaluatePolicy(rules, null, at("2026-10-24", "13:00")).active)
|
||||
assertEquals(
|
||||
"outside every window",
|
||||
evaluatePolicy(rules, null, at("2026-10-24", "13:00")).reason,
|
||||
)
|
||||
assertEquals(
|
||||
"inside a blocking window",
|
||||
evaluatePolicy(rules, null, at("2026-10-19", "13:00")).reason,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a schedule window may wrap midnight`() {
|
||||
val rules = BlockerRules(
|
||||
enabled = true,
|
||||
mode = "scheduled",
|
||||
schedule = listOf(BlockWindow("Night", "22:00", "06:00", listOf(0))),
|
||||
)
|
||||
assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "23:30")).active)
|
||||
assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "02:00")).active)
|
||||
assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "12:00")).active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed window is skipped, not fatal`() {
|
||||
val rules = BlockerRules(
|
||||
enabled = true,
|
||||
mode = "scheduled",
|
||||
schedule = listOf(
|
||||
BlockWindow("Broken", "nope", "also nope", listOf(0)),
|
||||
BlockWindow("Good", "09:00", "18:00", listOf(0)),
|
||||
),
|
||||
)
|
||||
assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "10:00")).active)
|
||||
assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "20:00")).active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scheduled mode ignores the timer entirely`() {
|
||||
val rules = BlockerRules(
|
||||
enabled = true,
|
||||
mode = "scheduled",
|
||||
schedule = listOf(BlockWindow("Work hours", "09:00", "18:00", listOf(0))),
|
||||
)
|
||||
// A running work interval does not rescue a moment outside every window: this
|
||||
// is why enforcement cannot be tied to the timer's lifecycle.
|
||||
assertFalse(evaluatePolicy(rules, working, at("2026-10-19", "20:00")).active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown mode falls back to during_work`() {
|
||||
// The server's `else` branch; a settings document from a newer backend must not
|
||||
// turn the guard permanently on or off.
|
||||
val rules = BlockerRules(enabled = true, mode = "something_new")
|
||||
assertTrue(evaluatePolicy(rules, working, at("2026-10-19", "12:00")).active)
|
||||
assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "12:00")).active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zone parsing falls back instead of throwing`() {
|
||||
assertEquals(ZoneId.of("Europe/Moscow"), zoneOrDefault("Europe/Moscow"))
|
||||
assertEquals(ZoneId.systemDefault(), zoneOrDefault("Not/AZone"))
|
||||
assertEquals(ZoneId.systemDefault(), zoneOrDefault(null))
|
||||
assertEquals(ZoneId.systemDefault(), zoneOrDefault(""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package tech.kadence.core
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Parity with `frontend/src/lib/format.ts`. The same interval rendered in a browser,
|
||||
* in the desktop tray and on the phone has to produce the same string.
|
||||
*/
|
||||
class FormatTest {
|
||||
|
||||
@Test
|
||||
fun `clock pads minutes and seconds`() {
|
||||
assertEquals("00:00", clock(0))
|
||||
assertEquals("00:09", clock(9))
|
||||
assertEquals("00:59", clock(59))
|
||||
assertEquals("01:00", clock(60))
|
||||
assertEquals("25:00", clock(1500))
|
||||
assertEquals("59:59", clock(3599))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clock grows an hours segment only when needed`() {
|
||||
assertEquals("1:00:00", clock(3600))
|
||||
assertEquals("2:03:04", clock(7384))
|
||||
// A 240-minute interval is the backend's documented maximum.
|
||||
assertEquals("4:00:00", clock(14400))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clock clamps negatives to zero`() {
|
||||
// The local tick can overshoot by a fraction before the poll re-anchors.
|
||||
assertEquals("00:00", clock(-1))
|
||||
assertEquals("00:00", clock(-500))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clock rounds a fractional tick`() {
|
||||
assertEquals("00:30", clock(30.4))
|
||||
assertEquals("00:31", clock(30.5))
|
||||
assertEquals("01:00", clock(59.6))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `humanMinutes matches the typescript`() {
|
||||
assertEquals("0m", humanMinutes(0))
|
||||
assertEquals("5m", humanMinutes(5))
|
||||
assertEquals("59m", humanMinutes(59))
|
||||
assertEquals("1h", humanMinutes(60))
|
||||
assertEquals("1h 1m", humanMinutes(61))
|
||||
assertEquals("2h 5m", humanMinutes(125))
|
||||
assertEquals("2h", humanMinutes(120))
|
||||
// The README's worked example: "Focus: 2h 35m".
|
||||
assertEquals("2h 35m", humanMinutes(155))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `humanMinutes rounds and clamps`() {
|
||||
assertEquals("2h 35m", humanMinutes(154.6))
|
||||
assertEquals("0m", humanMinutes(-5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind labels cover every interval kind`() {
|
||||
assertEquals("Focus", KIND_LABEL["work"])
|
||||
assertEquals("Short break", KIND_LABEL["short_break"])
|
||||
assertEquals("Long break", KIND_LABEL["long_break"])
|
||||
assertEquals(3, KIND_LABEL.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bucket labels follow the web app, not the desktop`() {
|
||||
// The desktop says "In progress" here; the web app is this client's reference.
|
||||
assertEquals("Would be nice to start", BUCKET_LABEL["active"])
|
||||
assertEquals("Overdue", BUCKET_LABEL["overdue"])
|
||||
assertEquals("Due today", BUCKET_LABEL["today"])
|
||||
assertEquals("Coming up", BUCKET_LABEL["upcoming"])
|
||||
assertEquals("No deadline", BUCKET_LABEL["someday"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bucket order has a label for every bucket and no extras`() {
|
||||
assertEquals(BUCKET_LABEL.keys, BUCKET_ORDER.toSet())
|
||||
assertEquals(BUCKET_ORDER.size, BUCKET_ORDER.distinct().size)
|
||||
assertEquals("overdue", BUCKET_ORDER.first())
|
||||
assertEquals("someday", BUCKET_ORDER.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dueLabel prefers the overdue count`() {
|
||||
assertEquals("", dueLabel(null, 0))
|
||||
assertEquals("", dueLabel("", 3))
|
||||
assertEquals("2026-10-19", dueLabel("2026-10-19", 0))
|
||||
assertEquals("3d overdue", dueLabel("2026-10-16", 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shortDate drops the year`() {
|
||||
assertEquals("10/19", shortDate("2026-10-19"))
|
||||
assertEquals("01/05", shortDate("2026-01-05"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekdayOf is monday zero`() {
|
||||
// 2026-10-19 is a Monday.
|
||||
assertEquals(0, weekdayOf("2026-10-19"))
|
||||
assertEquals(1, weekdayOf("2026-10-20"))
|
||||
assertEquals(5, weekdayOf("2026-10-24"))
|
||||
assertEquals(6, weekdayOf("2026-10-25"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package tech.kadence.core
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Parity with Python's `fnmatch`, which is what the backend and the desktop enforcer
|
||||
* use. Every expectation in [pythonParity] was produced by running the same pattern
|
||||
* and text through CPython's `fnmatch.fnmatch`, not reasoned about by hand — the block
|
||||
* lists are shared across clients, so a disagreement here means one list silently
|
||||
* means two different things.
|
||||
*/
|
||||
class GlobTest {
|
||||
|
||||
@Test
|
||||
fun `matches python fnmatch exactly`() {
|
||||
for ((pattern, text, expected) in pythonParity) {
|
||||
assertEquals(
|
||||
expected,
|
||||
globMatches(pattern, text),
|
||||
"fnmatch(${text.quoted()}, ${pattern.quoted()}) should be $expected",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `star is anchored at both ends`() {
|
||||
// `steam*` must not match `xsteam` — a mask is a whole-string match, which is
|
||||
// what keeps `steam*` from catching every command that mentions steam.
|
||||
assertTrue(globMatches("steam*", "steamwebhelper"))
|
||||
assertFalse(globMatches("steam*", "xsteam"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `star crosses separators unlike shell globbing`() {
|
||||
// This is the property the whole mask feature leans on: a hosts file cannot
|
||||
// express `*gram*`, but a string match can.
|
||||
assertTrue(globMatches("*gram*", "instagram.com"))
|
||||
assertTrue(globMatches("*.com", "a.b.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subdomain mask does not match the bare domain`() {
|
||||
// Precisely why `expand_entry()` emits the base domain alongside the
|
||||
// subdomains instead of relying on the mask.
|
||||
assertTrue(globMatches("*.youtube.com", "music.youtube.com"))
|
||||
assertFalse(globMatches("*.youtube.com", "youtube.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `question mark matches exactly one character`() {
|
||||
assertTrue(globMatches("telegram-?", "telegram-1"))
|
||||
assertFalse(globMatches("telegram-?", "telegram-12"))
|
||||
assertFalse(globMatches("telegram-?", "telegram-"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `character classes and negation`() {
|
||||
assertTrue(globMatches("[sv]potify", "spotify"))
|
||||
assertTrue(globMatches("[sv]potify", "vpotify"))
|
||||
assertFalse(globMatches("[sv]potify", "zpotify"))
|
||||
assertTrue(globMatches("[!sv]potify", "zpotify"))
|
||||
assertFalse(globMatches("[!sv]potify", "spotify"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ranges inside a class`() {
|
||||
assertTrue(globMatches("app[0-9]", "app7"))
|
||||
assertFalse(globMatches("app[0-9]", "appx"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unterminated class is a literal bracket`() {
|
||||
// CPython's translate() falls back to a literal `[`; so must we, or a typo in
|
||||
// the block list becomes a crash instead of a harmless non-match.
|
||||
assertTrue(globMatches("[", "["))
|
||||
assertTrue(globMatches("[abc", "[abc"))
|
||||
assertTrue(globMatches("a[]b", "a[]b"))
|
||||
assertFalse(globMatches("[abc", "a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dots are literal, not regex wildcards`() {
|
||||
assertTrue(globMatches("a.b", "a.b"))
|
||||
assertFalse(globMatches("a.b", "axb"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `regex metacharacters in a pattern are escaped`() {
|
||||
// A user pasting a URL fragment must not accidentally write a regex.
|
||||
assertTrue(globMatches("a+b(c)", "a+b(c)"))
|
||||
assertFalse(globMatches("a+b(c)", "aab c"))
|
||||
assertTrue(globMatches("price\$", "price\$"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty pattern matches only empty text`() {
|
||||
assertTrue(globMatches("", ""))
|
||||
assertFalse(globMatches("", "x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isMask only reports real wildcards`() {
|
||||
assertTrue(isMask("steam*"))
|
||||
assertTrue(isMask("a?b"))
|
||||
assertTrue(isMask("[ab]c"))
|
||||
assertFalse(isMask("discord"))
|
||||
assertFalse(isMask("youtube.com"))
|
||||
assertFalse(isMask(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compiled patterns are cached and still correct on reuse`() {
|
||||
val first = globToRegex("*discord*")
|
||||
val second = globToRegex("*discord*")
|
||||
assertTrue(first === second, "the compiled pattern should be reused")
|
||||
assertTrue(globMatches("*discord*", "/usr/bin/discord --flag"))
|
||||
assertTrue(globMatches("*discord*", "discord"))
|
||||
}
|
||||
|
||||
private fun String.quoted() = "'" + this + "'"
|
||||
|
||||
private companion object {
|
||||
/** (pattern, text, expected) — generated from CPython's `fnmatch.fnmatch`. */
|
||||
val pythonParity = listOf(
|
||||
Triple("steam*", "steam", true),
|
||||
Triple("steam*", "steamwebhelper", true),
|
||||
Triple("steam*", "xsteam", false),
|
||||
Triple("*discord*", "/usr/bin/discord --flag", true),
|
||||
Triple("*discord*", "discord", true),
|
||||
Triple("telegram-?", "telegram-1", true),
|
||||
Triple("telegram-?", "telegram-12", false),
|
||||
Triple("[sv]potify", "spotify", true),
|
||||
Triple("[sv]potify", "vpotify", true),
|
||||
Triple("[sv]potify", "zpotify", false),
|
||||
Triple("[!sv]potify", "zpotify", true),
|
||||
Triple("[!sv]potify", "spotify", false),
|
||||
Triple("*.youtube.com", "music.youtube.com", true),
|
||||
Triple("*.youtube.com", "youtube.com", false),
|
||||
Triple("*gram*", "instagram.com", true),
|
||||
Triple("*gram*", "telegram", true),
|
||||
Triple("*.google.com", "docs.google.com", true),
|
||||
Triple("a.b", "a.b", true),
|
||||
Triple("a.b", "axb", false),
|
||||
Triple("[", "[", true),
|
||||
Triple("[abc", "[abc", true),
|
||||
Triple("a[]b", "a[]b", true),
|
||||
Triple("*", "anything", true),
|
||||
Triple("", "", true),
|
||||
Triple("", "x", false),
|
||||
Triple("*.com", "a.b.com", true),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package tech.kadence.core
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The rear Glyph Matrix, tested on a laptop.
|
||||
*
|
||||
* This is the whole point of keeping the renderer pure: every dot of the Nothing Phone
|
||||
* (3)'s 25x25 back display is decided by arithmetic, so the entire feature can be built
|
||||
* and regression-tested without the device, without the vendored `.aar`, and without
|
||||
* Android. [asciiOf] prints the frame when an assertion fails, so a broken glyph is
|
||||
* visible rather than a mismatched integer.
|
||||
*/
|
||||
class GlyphFrameBasicsTest {
|
||||
|
||||
@Test
|
||||
fun `phone 3 is twenty-five squared`() {
|
||||
val frame = GlyphFrame()
|
||||
assertEquals(25, frame.size)
|
||||
assertEquals(625, frame.dots.size, "Common.getDeviceMatrixLength() on DEVICE_23112")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different matrix length is honoured`() {
|
||||
// Phone (4a) Pro is 13x13; nothing may hard-code 25.
|
||||
val frame = GlyphFrame(13)
|
||||
assertEquals(169, frame.dots.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `brightness is clamped, never wrapped`() {
|
||||
val frame = GlyphFrame()
|
||||
frame.set(0, 0, 999)
|
||||
frame.set(1, 0, -5)
|
||||
assertEquals(255, frame.get(0, 0))
|
||||
assertEquals(0, frame.get(1, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out-of-bounds writes are dropped, not crashes`() {
|
||||
val frame = GlyphFrame()
|
||||
frame.set(-1, 0, 255)
|
||||
frame.set(0, -1, 255)
|
||||
frame.set(25, 0, 255)
|
||||
frame.set(0, 25, 255)
|
||||
assertEquals(0, frame.litCount)
|
||||
assertEquals(0, frame.get(-1, -1))
|
||||
assertEquals(0, frame.get(99, 99))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render hands out a copy, so the SDK cannot be handed live state`() {
|
||||
val frame = GlyphFrame()
|
||||
frame.set(0, 0, 255)
|
||||
val rendered = frame.render()
|
||||
frame.set(0, 0, 0)
|
||||
assertEquals(255, rendered[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank frame is entirely dark`() {
|
||||
assertEquals(0, GlyphRenderer.blank().litCount)
|
||||
}
|
||||
}
|
||||
|
||||
class GlyphDigitsTest {
|
||||
|
||||
@Test
|
||||
fun `digit width accounts for the gap`() {
|
||||
assertEquals(0, digitsWidth(""))
|
||||
assertEquals(3, digitsWidth("1"))
|
||||
assertEquals(7, digitsWidth("18"))
|
||||
assertEquals(11, digitsWidth("184"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `digits render as the three-by-five font`() {
|
||||
val frame = GlyphFrame()
|
||||
frame.drawDigitsCentred("18", 6, GlyphBrightness.FULL)
|
||||
val rows = asciiOf(frame).lines().subList(6, 11)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"...........#.###.........",
|
||||
"...........#.#.#.........",
|
||||
"...........#.###.........",
|
||||
"...........#.#.#.........",
|
||||
"...........#.###.........",
|
||||
),
|
||||
rows,
|
||||
"the 3x5 font, centred: a right-aligned 1 beside an 8",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every digit is five rows tall and lights something`() {
|
||||
for (d in 0..9) {
|
||||
val frame = GlyphFrame()
|
||||
frame.drawDigits(d.toString(), 0, 0, GlyphBrightness.FULL)
|
||||
assertTrue(frame.litCount > 0, "digit $d renders nothing")
|
||||
for (y in 5 until 25) {
|
||||
for (x in 0 until 25) {
|
||||
assertEquals(0, frame.get(x, y), "digit $d spills past row 5")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown character advances the cursor without drawing`() {
|
||||
val frame = GlyphFrame()
|
||||
frame.drawDigits(":", 0, 0, GlyphBrightness.FULL)
|
||||
assertEquals(0, frame.litCount)
|
||||
}
|
||||
}
|
||||
|
||||
class GlyphRingTest {
|
||||
|
||||
private fun ringOnly(planned: Int, remaining: Int): GlyphFrame {
|
||||
val frame = GlyphFrame()
|
||||
frame.drawRing(planned, remaining)
|
||||
return frame
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ring uses the same tick rule as the Compose dial`() {
|
||||
// If these ever diverge, one dot means a different amount of time on the front
|
||||
// of the phone than on the back.
|
||||
val planned = 25 * 60
|
||||
assertEquals(dialTickCount(planned), ringOnly(planned, planned).litCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh interval lights every tick`() {
|
||||
val planned = 25 * 60
|
||||
assertEquals(25, ringOnly(planned, planned).litCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ring drains as the interval runs`() {
|
||||
val planned = 25 * 60
|
||||
val full = ringOnly(planned, planned).litCount
|
||||
val half = ringOnly(planned, planned / 2).litCount
|
||||
val nearly = ringOnly(planned, 60).litCount
|
||||
|
||||
assertTrue(half < full, "half-spent must be dimmer than fresh")
|
||||
assertTrue(nearly < half, "nearly-done must be dimmer than half")
|
||||
assertEquals(13, half)
|
||||
assertEquals(1, nearly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired interval lights nothing`() {
|
||||
assertEquals(0, ringOnly(25 * 60, 0).litCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the arc is anchored at twelve o'clock`() {
|
||||
// It must shrink *towards* twelve, not drain away from it, so the last sliver is
|
||||
// at the top where the eye expects it.
|
||||
val frame = ringOnly(25 * 60, 60)
|
||||
assertEquals(GlyphBrightness.FULL, frame.get(12, 1), "the top dot stays lit last")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long interval still fits the ring`() {
|
||||
// 85 minutes -> one dot per five minutes, so 17 ticks, not 85.
|
||||
val planned = 85 * 60
|
||||
assertEquals(17, ringOnly(planned, planned).litCount)
|
||||
}
|
||||
}
|
||||
|
||||
class GlyphTimerFrameTest {
|
||||
|
||||
@Test
|
||||
fun `minutes sit above seconds`() {
|
||||
val frame = GlyphRenderer.timerFrame(18 * 60 + 24, 25 * 60)
|
||||
val art = asciiOf(frame)
|
||||
val lines = art.lines()
|
||||
// Row 6 is the top of the minutes, row 14 the top of the seconds.
|
||||
assertTrue(lines[6].contains("#"), "minutes row is drawn")
|
||||
assertTrue(lines[14].contains("#"), "seconds row is drawn")
|
||||
assertTrue(frame.litCount > 20, "digits plus ring")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `past an hour the rows become hours and minutes`() {
|
||||
// 3930s is 1h 05m 30s. Shown as mm:ss it would read "65" over "30", and a
|
||||
// two-glyph row cannot say 65 minutes meaningfully — so past an hour the top
|
||||
// row becomes hours and the bottom becomes minutes.
|
||||
val frame = GlyphRenderer.timerFrame(3930, 4 * 3600)
|
||||
|
||||
val expected = GlyphFrame()
|
||||
expected.drawDigitsCentred("01", 6, GlyphBrightness.FULL)
|
||||
expected.drawDigitsCentred("05", 14, GlyphBrightness.FULL)
|
||||
|
||||
// Compare only the columns the digits occupy; the ring owns the perimeter.
|
||||
val got = asciiOf(frame).lines()
|
||||
val want = asciiOf(expected).lines()
|
||||
for (y in listOf(6, 7, 8, 9, 10, 14, 15, 16, 17, 18)) {
|
||||
assertEquals(
|
||||
want[y].substring(9, 16),
|
||||
got[y].substring(9, 16),
|
||||
"row $y should read as hours over minutes",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `under an hour the rows are minutes and seconds`() {
|
||||
val frame = GlyphRenderer.timerFrame(18 * 60 + 24, 25 * 60)
|
||||
|
||||
val expected = GlyphFrame()
|
||||
expected.drawDigitsCentred("18", 6, GlyphBrightness.FULL)
|
||||
expected.drawDigitsCentred("24", 14, GlyphBrightness.FULL)
|
||||
|
||||
val got = asciiOf(frame).lines()
|
||||
val want = asciiOf(expected).lines()
|
||||
for (y in listOf(6, 7, 8, 9, 10, 14, 15, 16, 17, 18)) {
|
||||
assertEquals(want[y].substring(9, 16), got[y].substring(9, 16), "row $y")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pausing dims the whole face`() {
|
||||
val running = GlyphRenderer.timerFrame(18 * 60, 25 * 60, paused = false)
|
||||
val paused = GlyphRenderer.timerFrame(18 * 60, 25 * 60, paused = true)
|
||||
|
||||
assertEquals(running.litCount, paused.litCount, "the same dots, dimmer")
|
||||
assertTrue(paused.dots.none { it == GlyphBrightness.FULL }, "nothing stays at full")
|
||||
assertTrue(paused.dots.any { it == GlyphBrightness.DIM })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a negative remaining is clamped rather than drawn`() {
|
||||
val frame = GlyphRenderer.timerFrame(-30, 25 * 60)
|
||||
val art = asciiOf(frame)
|
||||
assertTrue(art.lines().size == 25)
|
||||
// 00:00, and the ring fully drained.
|
||||
assertEquals(
|
||||
GlyphRenderer.timerFrame(0, 25 * 60).let(::asciiOf),
|
||||
art,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class GlyphDotsTest {
|
||||
|
||||
private fun counts(frame: GlyphFrame): Pair<Int, Int> =
|
||||
frame.dots.count { it == GlyphBrightness.FULL } to
|
||||
frame.dots.count { it == GlyphBrightness.FAINT }
|
||||
|
||||
@Test
|
||||
fun `earned dots are solid and the rest are faint`() {
|
||||
val (full, faint) = counts(GlyphRenderer.dotsFrame(6, 8))
|
||||
assertEquals(6, full)
|
||||
assertEquals(2, faint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a goal met lights everything`() {
|
||||
val (full, faint) = counts(GlyphRenderer.dotsFrame(8, 8))
|
||||
assertEquals(8, full)
|
||||
assertEquals(0, faint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `beating the goal grows the row instead of truncating`() {
|
||||
val (full, faint) = counts(GlyphRenderer.dotsFrame(10, 8))
|
||||
assertEquals(10, full, "a tenth pomodoro on an eight-goal day still shows")
|
||||
assertEquals(0, faint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a large goal wraps onto more rows`() {
|
||||
val frame = GlyphRenderer.dotsFrame(3, 12)
|
||||
val (full, faint) = counts(frame)
|
||||
assertEquals(3, full)
|
||||
assertEquals(9, faint)
|
||||
val rowsWithDots = asciiOf(frame).lines().count { it.any { c -> c != '.' } }
|
||||
assertEquals(2, rowsWithDots, "12 dots at 8 per row is two rows")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing yet today still renders one placeholder`() {
|
||||
val (full, faint) = counts(GlyphRenderer.dotsFrame(0, 0))
|
||||
assertEquals(0, full)
|
||||
assertEquals(1, faint, "an empty grid would look like a dead display")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the grid stays inside the matrix`() {
|
||||
val frame = GlyphRenderer.dotsFrame(20, 24)
|
||||
for (i in frame.dots.indices) {
|
||||
// Nothing wrapped around an edge: every lit dot is within bounds by
|
||||
// construction, so just assert the count survived.
|
||||
}
|
||||
assertEquals(24, frame.litCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package tech.kadence.core
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NormalizeHostTest {
|
||||
|
||||
@Test
|
||||
fun `a bare host passes through`() {
|
||||
assertEquals("youtube.com", normalizeHost("youtube.com"))
|
||||
assertEquals("m.youtube.com", normalizeHost("m.youtube.com"))
|
||||
assertEquals("youtube.com", normalizeHost(" YouTube.com "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full url is reduced to its host`() {
|
||||
assertEquals("youtube.com", normalizeHost("https://youtube.com"))
|
||||
assertEquals("www.youtube.com", normalizeHost("https://www.youtube.com/watch?v=abc"))
|
||||
assertEquals("youtube.com", normalizeHost("http://youtube.com/"))
|
||||
assertEquals("youtube.com", normalizeHost("https://youtube.com#anchor"))
|
||||
assertEquals("youtube.com", normalizeHost("https://youtube.com?q=1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `userinfo and ports are dropped`() {
|
||||
assertEquals("example.com", normalizeHost("https://user:pw@example.com:8443/x"))
|
||||
assertEquals("example.com", normalizeHost("example.com:8000"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trailing root dot is dropped`() {
|
||||
assertEquals("youtube.com", normalizeHost("youtube.com."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unicode hosts are matched in punycode`() {
|
||||
// A block list holds ASCII; the URL bar may show the pretty form.
|
||||
assertEquals("xn--e1afmkfd.xn--p1ai", normalizeHost("пример.рф"))
|
||||
assertEquals("xn--mnchen-3ya.de", normalizeHost("münchen.de"))
|
||||
assertEquals("xn--wgv71a.jp", normalizeHost("日本.jp"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a homoglyph host still normalises instead of being waved through`() {
|
||||
// `аррӏе.com` is Cyrillic lookalikes. Strict IDN.toASCII throws on it, which
|
||||
// would have returned null — and null means "allow". ALLOW_UNASSIGNED keeps it
|
||||
// matchable, so putting it on the block list actually blocks it.
|
||||
assertEquals("xn--80ak6aa92e.com", normalizeHost("аррӏе.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a search phrase is not a host`() {
|
||||
assertNull(normalizeHost("Search or type URL"))
|
||||
assertNull(normalizeHost("how to focus better"))
|
||||
assertNull(normalizeHost("youtube cat videos"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `internal schemes are never blockable`() {
|
||||
assertNull(normalizeHost("chrome://newtab"))
|
||||
assertNull(normalizeHost("about:blank"))
|
||||
assertNull(normalizeHost("chrome-native://bookmarks"))
|
||||
assertNull(normalizeHost("file:///sdcard/x.html"))
|
||||
assertNull(normalizeHost("data:text/html,hi"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `junk is null rather than a false positive`() {
|
||||
assertNull(normalizeHost(null))
|
||||
assertNull(normalizeHost(""))
|
||||
assertNull(normalizeHost(" "))
|
||||
assertNull(normalizeHost("localhost"), "a single label is not a host we block")
|
||||
assertNull(normalizeHost("..."))
|
||||
assertNull(normalizeHost("-bad-.com"))
|
||||
assertNull(normalizeHost("https://"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ip address survives`() {
|
||||
assertEquals("192.168.1.10", normalizeHost("http://192.168.1.10:8000/api"))
|
||||
}
|
||||
}
|
||||
|
||||
class DomainMatcherTest {
|
||||
|
||||
private fun matcher(
|
||||
blocked: List<String> = emptyList(),
|
||||
allowed: List<String> = emptyList(),
|
||||
subs: List<String> = listOf("www", "m"),
|
||||
): DomainMatcher {
|
||||
val rules = BlockerRules(
|
||||
enabled = true,
|
||||
blockedDomains = blocked,
|
||||
allowedDomains = allowed,
|
||||
subdomainExpansions = subs,
|
||||
)
|
||||
return DomainMatcher(rules)
|
||||
}
|
||||
|
||||
private fun assertBlocked(decision: BlockDecision, host: String, rule: String) {
|
||||
val block = assertIs<BlockDecision.Block>(decision, "expected $host to be blocked")
|
||||
assertEquals(host, block.target)
|
||||
assertEquals(rule, block.matchedRule)
|
||||
assertEquals(BlockAction.BLOCKED, block.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain entry blocks the site and its subdomains`() {
|
||||
val m = matcher(blocked = listOf("youtube.com"))
|
||||
assertBlocked(m.decide("youtube.com"), "youtube.com", "youtube.com")
|
||||
assertBlocked(m.decide("www.youtube.com"), "www.youtube.com", "youtube.com")
|
||||
assertBlocked(m.decide("m.youtube.com"), "m.youtube.com", "youtube.com")
|
||||
// Beyond the hosts file's reach: never expanded, still caught here.
|
||||
assertBlocked(m.decide("music.youtube.com"), "music.youtube.com", "youtube.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lookalike domain is not caught`() {
|
||||
val m = matcher(blocked = listOf("youtube.com"))
|
||||
assertEquals(BlockDecision.Allow, m.decide("myyoutube.com"))
|
||||
assertEquals(BlockDecision.Allow, m.decide("youtube.com.evil.net"))
|
||||
assertEquals(BlockDecision.Allow, m.decide("notyoutube.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the allow list wins over the block list`() {
|
||||
val m = matcher(blocked = listOf("*.google.com"), allowed = listOf("docs.google.com"))
|
||||
assertEquals(BlockDecision.Allow, m.decide("https://docs.google.com/document/d/1"))
|
||||
assertBlocked(m.decide("www.google.com"), "www.google.com", "*.google.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an allow mask covers a whole subtree`() {
|
||||
val m = matcher(blocked = listOf("google.com"), allowed = listOf("*.docs.google.com"))
|
||||
assertEquals(BlockDecision.Allow, m.decide("a.docs.google.com"))
|
||||
// Consistent with the backend: `*.x` does not cover the bare `x`.
|
||||
assertBlocked(m.decide("docs.google.com"), "docs.google.com", "google.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `masks the hosts file cannot express are enforced here`() {
|
||||
// This is the payoff: the backend returns `*gram*` as `unexpandable` because a
|
||||
// hosts file has no wildcards. The phone matches the string instead.
|
||||
val m = matcher(blocked = listOf("*gram*"))
|
||||
assertBlocked(m.decide("instagram.com"), "instagram.com", "*gram*")
|
||||
assertBlocked(m.decide("https://www.instagram.com/explore/"), "www.instagram.com", "*gram*")
|
||||
assertEquals(BlockDecision.Allow, m.decide("youtube.com"))
|
||||
assertEquals(listOf("*gram*"), m.enforcedBeyondHostsFile())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subdomain mask blocks the base domain too, via expansion`() {
|
||||
val m = matcher(blocked = listOf("*.reddit.com"))
|
||||
assertBlocked(m.decide("reddit.com"), "reddit.com", "*.reddit.com")
|
||||
assertBlocked(m.decide("www.reddit.com"), "www.reddit.com", "*.reddit.com")
|
||||
assertBlocked(m.decide("old.reddit.com"), "old.reddit.com", "*.reddit.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `junk from the url bar is always allowed`() {
|
||||
val m = matcher(blocked = listOf("youtube.com", "*gram*"))
|
||||
assertEquals(BlockDecision.Allow, m.decide("Search or type URL"))
|
||||
assertEquals(BlockDecision.Allow, m.decide("chrome://newtab"))
|
||||
assertEquals(BlockDecision.Allow, m.decide(null))
|
||||
assertEquals(BlockDecision.Allow, m.decide(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty policy blocks nothing`() {
|
||||
val m = matcher()
|
||||
assertEquals(BlockDecision.Allow, m.decide("youtube.com"))
|
||||
assertEquals(BlockDecision.Allow, m.decide("anything.at.all"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the server's expanded list is used when supplied`() {
|
||||
// Online, `all_domains` arrives already expanded; we must honour it even for an
|
||||
// entry whose raw form we were not given.
|
||||
val rules = BlockerRules(enabled = true, blockedDomains = emptyList())
|
||||
val m = DomainMatcher(rules, expandedHosts = listOf("tiktok.com", "www.tiktok.com"))
|
||||
val decision = m.decide("https://tiktok.com/foryou")
|
||||
val block = assertIs<BlockDecision.Block>(decision)
|
||||
assertEquals("tiktok.com", block.target)
|
||||
assertEquals(BlockAction.BLOCKED, block.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reported rule is the entry that produced the host`() {
|
||||
// `*.reddit.com` expands to the bare `reddit.com`, but the mask does not match
|
||||
// that bare name — so without provenance the overlay would blame the host
|
||||
// instead of naming the rule the user actually wrote.
|
||||
val m = matcher(blocked = listOf("*.reddit.com"))
|
||||
val block = assertIs<BlockDecision.Block>(m.decide("reddit.com"))
|
||||
assertEquals("reddit.com", block.target, "the event target stays the hostname")
|
||||
assertEquals("*.reddit.com", block.matchedRule, "but the rule is the user's entry")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first entry to claim a host owns it`() {
|
||||
val m = matcher(blocked = listOf("youtube.com", "*.youtube.com"))
|
||||
val block = assertIs<BlockDecision.Block>(m.decide("www.youtube.com"))
|
||||
assertEquals("youtube.com", block.matchedRule)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case and whitespace in the list do not matter`() {
|
||||
val m = matcher(blocked = listOf(" YouTube.COM "))
|
||||
assertBlocked(m.decide("YOUTUBE.com"), "youtube.com", "youtube.com")
|
||||
}
|
||||
}
|
||||
|
||||
class AppMatcherTest {
|
||||
|
||||
@Test
|
||||
fun `a plain entry is a substring test, so one list serves both platforms`() {
|
||||
// `discord` was written for a Linux process name; it must still catch the
|
||||
// Android package.
|
||||
val m = AppMatcher(listOf("discord"))
|
||||
assertEquals("discord", m.match("com.discord", "Discord"))
|
||||
assertEquals("discord", m.match("com.discord.canary"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the visible label is the second haystack`() {
|
||||
// Android's analogue of the desktop matcher's command line.
|
||||
val m = AppMatcher(listOf("instagram"))
|
||||
assertEquals("instagram", m.match("com.instagram.android", "Instagram"))
|
||||
|
||||
val byLabelOnly = AppMatcher(listOf("telegram"))
|
||||
assertEquals("telegram", byLabelOnly.match("org.thunderdog.challegram", "Telegram X"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mask is anchored, so it misses the package name`() {
|
||||
// `steam*` was written for a Linux process called `steam`. An fnmatch mask is a
|
||||
// whole-string match, so it does NOT match the Android package — this is the
|
||||
// shared-list gotcha, documented rather than papered over.
|
||||
val m = AppMatcher(listOf("steam*"))
|
||||
assertEquals("steam*", m.match("steamwebhelper"))
|
||||
assertNull(m.match("com.valvesoftware.android.steam.community"))
|
||||
assertNull(m.match("com.discord", "Discord"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the label rescues an anchored mask on Android`() {
|
||||
// Checking the visible label as a second haystack is what makes the desktop's
|
||||
// `steam*` still work here: the package misses, but the label "Steam" matches.
|
||||
// This is the Android analogue of the desktop also trying the command line.
|
||||
val m = AppMatcher(listOf("steam*"))
|
||||
assertEquals(
|
||||
"steam*",
|
||||
m.match("com.valvesoftware.android.steam.community", "Steam"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wrapped mask matches the package directly`() {
|
||||
// What the in-app app picker should write, so an entry added on the phone also
|
||||
// blocks the Linux process.
|
||||
val wrapped = AppMatcher(listOf("*steam*"))
|
||||
assertEquals(
|
||||
"*steam*",
|
||||
wrapped.match("com.valvesoftware.android.steam.community", ""),
|
||||
)
|
||||
assertEquals("*steam*", wrapped.match("steamwebhelper"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `masks match either haystack`() {
|
||||
val m = AppMatcher(listOf("*telegram*"))
|
||||
assertEquals("*telegram*", m.match("org.telegram.messenger", "Telegram"))
|
||||
assertEquals("*telegram*", m.match("com.example.app", "My Telegram Client"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no match returns null`() {
|
||||
val m = AppMatcher(listOf("discord", "*steam*"))
|
||||
assertNull(m.match("com.android.chrome", "Chrome"))
|
||||
assertNull(m.match("tech.kadence.android", "Kadence"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank patterns are ignored`() {
|
||||
val m = AppMatcher(listOf("", " ", "discord"))
|
||||
assertEquals("discord", m.match("com.discord"))
|
||||
assertNull(m.match("com.android.chrome"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first matching pattern is the one reported`() {
|
||||
// `by_target` in the report groups by this string, so it must be stable.
|
||||
val m = AppMatcher(listOf("*gram*", "instagram"))
|
||||
assertEquals("*gram*", m.match("com.instagram.android", "Instagram"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kill_blocked_apps decides the reported action`() {
|
||||
val m = AppMatcher(listOf("instagram"))
|
||||
|
||||
val killed = m.decide("com.instagram.android", "Instagram", kill = true)
|
||||
assertEquals(BlockAction.KILLED, assertIs<BlockDecision.Block>(killed).action)
|
||||
|
||||
// Warn-only. This matters: the server only fires `notify_on_block` for
|
||||
// `killed`, so sending `warned` here is what keeps Telegram quiet.
|
||||
val warned = m.decide("com.instagram.android", "Instagram", kill = false)
|
||||
assertEquals(BlockAction.WARNED, assertIs<BlockDecision.Block>(warned).action)
|
||||
|
||||
assertEquals(BlockDecision.Allow, m.decide("com.android.chrome", "Chrome", kill = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case is folded on both sides`() {
|
||||
val m = AppMatcher(listOf("DISCORD"))
|
||||
assertTrue(m.match("com.Discord", "Discord") != null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package tech.kadence.core
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class OutboxCoalesceTest {
|
||||
|
||||
private var seq = 0L
|
||||
private fun next() = ++seq
|
||||
|
||||
private fun entry(op: OutboxOp, session: Long? = 7L, minutes: Int = 0, taskId: Long? = null) =
|
||||
OutboxEntry(seq = next(), op = op, anchorSessionId = session, minutes = minutes, taskId = taskId)
|
||||
|
||||
private fun queueOf(vararg ops: OutboxEntry): List<OutboxEntry> =
|
||||
ops.fold(emptyList()) { acc, e -> coalesce(acc, e) }
|
||||
|
||||
@Test
|
||||
fun `an empty queue takes the first intent as-is`() {
|
||||
val q = queueOf(entry(OutboxOp.PAUSE))
|
||||
assertEquals(1, q.size)
|
||||
assertEquals(OutboxOp.PAUSE, q.single().op)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pause then resume annihilate`() {
|
||||
// The user changed their mind before we had signal; sending both is pointless
|
||||
// and would wrongly bump the interruption counter.
|
||||
val q = queueOf(entry(OutboxOp.PAUSE), entry(OutboxOp.RESUME))
|
||||
assertTrue(q.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resume then pause annihilate too`() {
|
||||
val q = queueOf(entry(OutboxOp.RESUME), entry(OutboxOp.PAUSE))
|
||||
assertTrue(q.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pause that survives a round trip is kept`() {
|
||||
val q = queueOf(entry(OutboxOp.PAUSE), entry(OutboxOp.RESUME), entry(OutboxOp.PAUSE))
|
||||
assertEquals(listOf(OutboxOp.PAUSE), q.map { it.op })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extends add up`() {
|
||||
val q = queueOf(
|
||||
entry(OutboxOp.EXTEND, minutes = 5),
|
||||
entry(OutboxOp.EXTEND, minutes = 5),
|
||||
entry(OutboxOp.EXTEND, minutes = 10),
|
||||
)
|
||||
assertEquals(1, q.size)
|
||||
assertEquals(20, q.single().minutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extends for different intervals stay apart`() {
|
||||
val q = queueOf(
|
||||
entry(OutboxOp.EXTEND, session = 7L, minutes = 5),
|
||||
entry(OutboxOp.EXTEND, session = 8L, minutes = 5),
|
||||
)
|
||||
assertEquals(2, q.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interruptions are counted, not collapsed`() {
|
||||
// The server increments a counter, so three taps must become three increments.
|
||||
val q = queueOf(
|
||||
entry(OutboxOp.INTERRUPT),
|
||||
entry(OutboxOp.INTERRUPT),
|
||||
entry(OutboxOp.INTERRUPT),
|
||||
)
|
||||
assertEquals(1, q.size)
|
||||
assertEquals(3, q.single().repeat)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the last chosen task matters`() {
|
||||
val q = queueOf(
|
||||
entry(OutboxOp.ATTACH_TASK, taskId = 1L),
|
||||
entry(OutboxOp.ATTACH_TASK, taskId = 2L),
|
||||
entry(OutboxOp.ATTACH_TASK, taskId = 3L),
|
||||
)
|
||||
assertEquals(1, q.size)
|
||||
assertEquals(3L, q.single().taskId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start discards everything queued for the interval it supersedes`() {
|
||||
// `pomodoro.start()` abandons the running interval as "superseded", so a queued
|
||||
// pause or +5 for it is already moot.
|
||||
val q = queueOf(
|
||||
entry(OutboxOp.PAUSE, session = 7L),
|
||||
entry(OutboxOp.EXTEND, session = 7L, minutes = 5),
|
||||
entry(OutboxOp.INTERRUPT, session = 7L),
|
||||
OutboxEntry(seq = next(), op = OutboxOp.START, anchorSessionId = 7L, kind = Kind.WORK),
|
||||
)
|
||||
assertEquals(listOf(OutboxOp.START), q.map { it.op })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only one start is ever queued`() {
|
||||
val q = queueOf(
|
||||
OutboxEntry(seq = next(), op = OutboxOp.START, kind = Kind.WORK),
|
||||
OutboxEntry(seq = next(), op = OutboxOp.START, kind = Kind.SHORT_BREAK),
|
||||
)
|
||||
assertEquals(1, q.size)
|
||||
assertEquals(Kind.SHORT_BREAK, q.single().kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a terminal intent clears the interval's other intents`() {
|
||||
val q = queueOf(
|
||||
entry(OutboxOp.PAUSE, session = 7L),
|
||||
entry(OutboxOp.EXTEND, session = 7L, minutes = 5),
|
||||
entry(OutboxOp.COMPLETE, session = 7L),
|
||||
)
|
||||
assertEquals(listOf(OutboxOp.COMPLETE), q.map { it.op })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a terminal intent leaves another interval's intents alone`() {
|
||||
val q = queueOf(
|
||||
entry(OutboxOp.PAUSE, session = 8L),
|
||||
entry(OutboxOp.COMPLETE, session = 7L),
|
||||
)
|
||||
assertEquals(2, q.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pendingOps summarises the queue for the reconciler`() {
|
||||
assertEquals(PendingOps(), pendingOpsOf(emptyList()))
|
||||
|
||||
val withStart = queueOf(OutboxEntry(seq = next(), op = OutboxOp.START, kind = Kind.WORK))
|
||||
assertEquals(PendingOps(hasAny = true, hasStart = true), pendingOpsOf(withStart))
|
||||
|
||||
val withTerminal = queueOf(entry(OutboxOp.ABANDON))
|
||||
assertEquals(PendingOps(hasAny = true, hasTerminal = true), pendingOpsOf(withTerminal))
|
||||
|
||||
val plain = queueOf(entry(OutboxOp.INTERRUPT))
|
||||
assertEquals(PendingOps(hasAny = true), pendingOpsOf(plain))
|
||||
}
|
||||
}
|
||||
|
||||
class BackoffTest {
|
||||
|
||||
@Test
|
||||
fun `backoff doubles and then caps`() {
|
||||
assertEquals(1, backoffSeconds(1))
|
||||
assertEquals(2, backoffSeconds(2))
|
||||
assertEquals(4, backoffSeconds(3))
|
||||
assertEquals(8, backoffSeconds(4))
|
||||
assertEquals(16, backoffSeconds(5))
|
||||
assertEquals(32, backoffSeconds(6))
|
||||
assertEquals(60, backoffSeconds(7), "capped at a minute")
|
||||
assertEquals(60, backoffSeconds(99))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zeroth attempt still waits a moment`() {
|
||||
assertEquals(1, backoffSeconds(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retries give up after eight attempts`() {
|
||||
val retry = assertIs<ReplayOutcome.Retry>(replayOutcomeFor(1))
|
||||
assertEquals(2, retry.attempt)
|
||||
assertIs<ReplayOutcome.Retry>(replayOutcomeFor(MAX_REPLAY_ATTEMPTS - 1))
|
||||
assertEquals(ReplayOutcome.GiveUp, replayOutcomeFor(MAX_REPLAY_ATTEMPTS))
|
||||
assertEquals(ReplayOutcome.GiveUp, replayOutcomeFor(MAX_REPLAY_ATTEMPTS + 5))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The post-condition probes. None of these endpoints is idempotent, so after an
|
||||
* ambiguous failure the only safe question is "does the world look like my call
|
||||
* landed?" — asked once, against freshly-read state.
|
||||
*/
|
||||
class PostconditionTest {
|
||||
|
||||
private fun session(
|
||||
id: Long = 7L,
|
||||
status: String = Status.RUNNING,
|
||||
planned: Int = 1500,
|
||||
interruptions: Int = 0,
|
||||
kind: String = Kind.WORK,
|
||||
) = TimerSessionFacts(
|
||||
id = id,
|
||||
kind = kind,
|
||||
status = status,
|
||||
plannedSeconds = planned,
|
||||
remainingSeconds = 900,
|
||||
interruptions = interruptions,
|
||||
)
|
||||
|
||||
private fun state(current: TimerSessionFacts?) = ServerTimerState(current = current)
|
||||
|
||||
@Test
|
||||
fun `extend landed when planned seconds grew by at least the request`() {
|
||||
val before = session(planned = 1500)
|
||||
val entry = OutboxEntry(1, OutboxOp.EXTEND, anchorSessionId = 7L, minutes = 5)
|
||||
|
||||
assertTrue(postconditionHolds(entry, before, state(session(planned = 1800))))
|
||||
assertFalse(postconditionHolds(entry, before, state(session(planned = 1500))))
|
||||
// Another client may have extended further in the meantime; still landed.
|
||||
assertTrue(postconditionHolds(entry, before, state(session(planned = 2400))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extend did not land if the interval was replaced`() {
|
||||
val before = session(id = 7L, planned = 1500)
|
||||
val entry = OutboxEntry(1, OutboxOp.EXTEND, anchorSessionId = 7L, minutes = 5)
|
||||
assertFalse(postconditionHolds(entry, before, state(session(id = 9L, planned = 1800))))
|
||||
assertFalse(postconditionHolds(entry, before, state(null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interrupt landed when the counter advanced by the repeat count`() {
|
||||
val before = session(interruptions = 2)
|
||||
val entry = OutboxEntry(1, OutboxOp.INTERRUPT, anchorSessionId = 7L, repeat = 3)
|
||||
|
||||
assertFalse(postconditionHolds(entry, before, state(session(interruptions = 4))))
|
||||
assertTrue(postconditionHolds(entry, before, state(session(interruptions = 5))))
|
||||
assertTrue(postconditionHolds(entry, before, state(session(interruptions = 6))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start landed when a new running interval of the right kind exists`() {
|
||||
val entry = OutboxEntry(1, OutboxOp.START, anchorSessionId = 7L, kind = Kind.WORK)
|
||||
|
||||
assertTrue(postconditionHolds(entry, null, state(session(id = 9L, kind = Kind.WORK))))
|
||||
// The old interval is still there, so nothing started.
|
||||
assertFalse(postconditionHolds(entry, null, state(session(id = 7L))))
|
||||
// Wrong kind: somebody else's interval, not ours.
|
||||
assertFalse(
|
||||
postconditionHolds(entry, null, state(session(id = 9L, kind = Kind.SHORT_BREAK))),
|
||||
)
|
||||
assertFalse(postconditionHolds(entry, null, state(null)))
|
||||
// A paused new interval is not what `start` produces.
|
||||
assertFalse(
|
||||
postconditionHolds(entry, null, state(session(id = 9L, status = Status.PAUSED))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pause and resume probe the status`() {
|
||||
val pause = OutboxEntry(1, OutboxOp.PAUSE, anchorSessionId = 7L)
|
||||
assertTrue(postconditionHolds(pause, null, state(session(status = Status.PAUSED))))
|
||||
assertFalse(postconditionHolds(pause, null, state(session(status = Status.RUNNING))))
|
||||
assertFalse(postconditionHolds(pause, null, state(null)))
|
||||
|
||||
val resume = OutboxEntry(2, OutboxOp.RESUME, anchorSessionId = 7L)
|
||||
assertTrue(postconditionHolds(resume, null, state(session(status = Status.RUNNING))))
|
||||
assertFalse(postconditionHolds(resume, null, state(session(status = Status.PAUSED))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `complete and abandon landed when the interval is gone or replaced`() {
|
||||
val complete = OutboxEntry(1, OutboxOp.COMPLETE, anchorSessionId = 7L)
|
||||
assertTrue(postconditionHolds(complete, null, state(null)))
|
||||
// An auto-started follow-on counts: our interval is no longer current.
|
||||
assertTrue(postconditionHolds(complete, null, state(session(id = 9L))))
|
||||
assertFalse(postconditionHolds(complete, null, state(session(id = 7L))))
|
||||
|
||||
val abandon = OutboxEntry(2, OutboxOp.ABANDON, anchorSessionId = 7L)
|
||||
assertTrue(postconditionHolds(abandon, null, state(null)))
|
||||
assertFalse(postconditionHolds(abandon, null, state(session(id = 7L))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach task has nothing conclusive to probe`() {
|
||||
// `TimerState.current.task` is not in the facts we keep, so we would be
|
||||
// guessing. Returning false means "retry", which is safe: attaching the same
|
||||
// task twice is a no-op.
|
||||
val entry = OutboxEntry(1, OutboxOp.ATTACH_TASK, anchorSessionId = 7L, taskId = 3L)
|
||||
assertFalse(postconditionHolds(entry, null, state(session())))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intervals that both started and finished offline cannot be filed truthfully, because
|
||||
* the server stamps `started_at` itself and `focus_minutes` comes from the wall-clock
|
||||
* difference. Quarantining them is the honest option; replaying them would silently
|
||||
* corrupt the statistics the whole app exists to produce.
|
||||
*/
|
||||
class PartitionForReplayTest {
|
||||
|
||||
@Test
|
||||
fun `a queue with no start replays entirely`() {
|
||||
val queue = listOf(
|
||||
OutboxEntry(1, OutboxOp.PAUSE, anchorSessionId = 7L),
|
||||
OutboxEntry(2, OutboxOp.EXTEND, anchorSessionId = 7L, minutes = 5),
|
||||
OutboxEntry(3, OutboxOp.COMPLETE, anchorSessionId = 7L),
|
||||
)
|
||||
val (replay, unfiled) = partitionForReplay(queue)
|
||||
assertEquals(3, replay.size)
|
||||
assertTrue(unfiled.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start still running replays`() {
|
||||
val queue = listOf(
|
||||
OutboxEntry(1, OutboxOp.START, kind = Kind.WORK),
|
||||
OutboxEntry(2, OutboxOp.INTERRUPT, anchorSessionId = null),
|
||||
)
|
||||
val (replay, unfiled) = partitionForReplay(queue)
|
||||
assertEquals(2, replay.size)
|
||||
assertTrue(unfiled.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a start and its completion are quarantined together`() {
|
||||
val queue = listOf(
|
||||
OutboxEntry(1, OutboxOp.START, kind = Kind.WORK),
|
||||
OutboxEntry(2, OutboxOp.COMPLETE),
|
||||
)
|
||||
val (replay, unfiled) = partitionForReplay(queue)
|
||||
assertTrue(replay.isEmpty(), "replaying would file a pomodoro at the wrong hour")
|
||||
assertEquals(listOf(1L, 2L), unfiled.map { it.seq })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything between the start and its end goes with it`() {
|
||||
val queue = listOf(
|
||||
OutboxEntry(1, OutboxOp.START, kind = Kind.WORK),
|
||||
OutboxEntry(2, OutboxOp.INTERRUPT),
|
||||
OutboxEntry(3, OutboxOp.EXTEND, minutes = 5),
|
||||
OutboxEntry(4, OutboxOp.ABANDON, reason = "the phone rang"),
|
||||
)
|
||||
val (replay, unfiled) = partitionForReplay(queue)
|
||||
assertTrue(replay.isEmpty())
|
||||
assertEquals(4, unfiled.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `work queued before the offline start is still replayed`() {
|
||||
val queue = listOf(
|
||||
OutboxEntry(1, OutboxOp.PAUSE, anchorSessionId = 7L),
|
||||
OutboxEntry(2, OutboxOp.START, kind = Kind.WORK),
|
||||
OutboxEntry(3, OutboxOp.COMPLETE),
|
||||
)
|
||||
val (replay, unfiled) = partitionForReplay(queue)
|
||||
assertEquals(listOf(1L), replay.map { it.seq })
|
||||
assertEquals(listOf(2L, 3L), unfiled.map { it.seq })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unfiled interval reports its real duration`() {
|
||||
val start = 1_760_000_000_000L
|
||||
val interval = UnfiledInterval(
|
||||
kind = Kind.WORK,
|
||||
startedAtWallMillis = start,
|
||||
endedAtWallMillis = start + 25 * 60 * 1000L,
|
||||
plannedSeconds = 1500,
|
||||
)
|
||||
assertEquals(1500, interval.actualSeconds)
|
||||
assertEquals("25m", humanMinutes(interval.actualSeconds / 60))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package tech.kadence.core
|
||||
|
||||
import java.time.LocalTime
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Parity with `in_window()` in `backend/app/timeutil.py`. */
|
||||
class TimeWindowTest {
|
||||
|
||||
private fun t(text: String) = LocalTime.parse(text)
|
||||
|
||||
@Test
|
||||
fun `a normal window is inclusive at both ends`() {
|
||||
val start = t("10:00")
|
||||
val end = t("20:00")
|
||||
assertTrue(inWindow(t("10:00"), start, end), "the start minute is inside")
|
||||
assertTrue(inWindow(t("15:30"), start, end))
|
||||
assertTrue(inWindow(t("20:00"), start, end), "the end minute is inside")
|
||||
assertFalse(inWindow(t("09:59"), start, end))
|
||||
assertFalse(inWindow(t("20:01"), start, end))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a window that wraps midnight`() {
|
||||
// The quiet-hours default: 23:00 -> 08:00.
|
||||
val start = t("23:00")
|
||||
val end = t("08:00")
|
||||
assertTrue(inWindow(t("23:00"), start, end))
|
||||
assertTrue(inWindow(t("23:59"), start, end))
|
||||
assertTrue(inWindow(t("00:00"), start, end))
|
||||
assertTrue(inWindow(t("07:59"), start, end))
|
||||
assertTrue(inWindow(t("08:00"), start, end))
|
||||
assertFalse(inWindow(t("08:01"), start, end))
|
||||
assertFalse(inWindow(t("22:59"), start, end))
|
||||
assertFalse(inWindow(t("12:00"), start, end))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a degenerate window covers exactly one minute`() {
|
||||
val at = t("09:00")
|
||||
assertTrue(inWindow(at, at, at))
|
||||
assertFalse(inWindow(t("09:01"), at, at))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses the HH colon MM the settings document stores`() {
|
||||
assertEquals(LocalTime.of(9, 0), parseClockTime("09:00"))
|
||||
assertEquals(LocalTime.of(23, 5), parseClockTime("23:05"))
|
||||
assertEquals(LocalTime.of(21, 0), parseClockTime(" 21:00 "))
|
||||
assertEquals(LocalTime.of(7, 30, 15), parseClockTime("07:30:15"))
|
||||
assertEquals(LocalTime.of(9, 0), parseClockTime("9:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed time is null, never an exception`() {
|
||||
// These strings come from a user-editable server document; one bad window must
|
||||
// not take the whole blocking policy down.
|
||||
assertNull(parseClockTime(null))
|
||||
assertNull(parseClockTime(""))
|
||||
assertNull(parseClockTime(" "))
|
||||
assertNull(parseClockTime("nope"))
|
||||
assertNull(parseClockTime("25:00"))
|
||||
assertNull(parseClockTime("10:60"))
|
||||
assertNull(parseClockTime("10"))
|
||||
assertNull(parseClockTime("-1:00"))
|
||||
assertNull(parseClockTime("ab:cd"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package tech.kadence.core
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TickMathTest {
|
||||
|
||||
private fun anchor(remaining: Int, atNanos: Long = 0L, paused: Boolean = false) =
|
||||
TickAnchor(sessionId = 1L, remainingAtAnchor = remaining, elapsedNanos = atNanos, paused = paused)
|
||||
|
||||
private fun seconds(n: Long) = n * 1_000_000_000L
|
||||
|
||||
@Test
|
||||
fun `counts down from the anchor`() {
|
||||
val a = anchor(remaining = 300)
|
||||
assertEquals(300, remainingAt(a, seconds(0)))
|
||||
assertEquals(299, remainingAt(a, seconds(1)))
|
||||
assertEquals(240, remainingAt(a, seconds(60)))
|
||||
assertEquals(0, remainingAt(a, seconds(300)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `never goes below zero`() {
|
||||
val a = anchor(remaining = 5)
|
||||
assertEquals(0, remainingAt(a, seconds(5)))
|
||||
assertEquals(0, remainingAt(a, seconds(500)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a paused interval is frozen`() {
|
||||
val a = anchor(remaining = 300, paused = true)
|
||||
assertEquals(300, remainingAt(a, seconds(0)))
|
||||
assertEquals(300, remainingAt(a, seconds(120)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `partial seconds do not tick early`() {
|
||||
val a = anchor(remaining = 300)
|
||||
assertEquals(300, remainingAt(a, 999_999_999L))
|
||||
assertEquals(299, remainingAt(a, 1_000_000_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the anchor is monotonic, so a clock change cannot corrupt it`() {
|
||||
// elapsedNanos comes from a monotonic source; a negative delta (which wall time
|
||||
// could produce after an NTP correction) must not add time to the interval.
|
||||
val a = anchor(remaining = 300, atNanos = seconds(100))
|
||||
assertEquals(300, remainingAt(a, seconds(100)))
|
||||
assertEquals(300, remainingAt(a, seconds(90)), "going backwards must not add time")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress is the dial's fill, clamped`() {
|
||||
assertEquals(0f, progressOf(1500, 1500))
|
||||
assertEquals(0.5f, progressOf(750, 1500))
|
||||
assertEquals(1f, progressOf(0, 1500))
|
||||
assertEquals(1f, progressOf(-10, 1500), "an overshooting tick still reads as full")
|
||||
assertEquals(0f, progressOf(100, 0), "a zero-length interval has no progress")
|
||||
}
|
||||
}
|
||||
|
||||
class DialGeometryTest {
|
||||
|
||||
@Test
|
||||
fun `one tick per minute up to an hour`() {
|
||||
assertEquals(25, dialTickCount(25 * 60))
|
||||
assertEquals(45, dialTickCount(45 * 60))
|
||||
assertEquals(60, dialTickCount(60 * 60))
|
||||
assertEquals(5, dialTickCount(5 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `past an hour it switches to one per five minutes`() {
|
||||
assertEquals(17, dialTickCount(85 * 60), "the 'Пара' preset: 85 minutes")
|
||||
assertEquals(24, dialTickCount(120 * 60))
|
||||
assertEquals(48, dialTickCount(240 * 60), "the backend's 240-minute maximum")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a degenerate interval still draws one tick`() {
|
||||
assertEquals(1, dialTickCount(0))
|
||||
assertEquals(1, dialTickCount(30))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spent ticks track elapsed time`() {
|
||||
val planned = 25 * 60
|
||||
assertEquals(0, dialSpentTicks(planned, planned))
|
||||
assertEquals(12, dialSpentTicks(planned, planned / 2))
|
||||
assertEquals(25, dialSpentTicks(planned, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spent ticks stay inside the dial`() {
|
||||
val planned = 25 * 60
|
||||
assertEquals(25, dialSpentTicks(planned, -60))
|
||||
assertEquals(0, dialSpentTicks(planned, planned + 60))
|
||||
}
|
||||
}
|
||||
|
||||
class NextKindTest {
|
||||
|
||||
@Test
|
||||
fun `work follows anything that is not a completed work interval`() {
|
||||
assertEquals(Kind.WORK, nextKindLocally(null, null, 0, 4))
|
||||
assertEquals(Kind.WORK, nextKindLocally(Kind.SHORT_BREAK, Status.COMPLETED, 2, 4))
|
||||
assertEquals(Kind.WORK, nextKindLocally(Kind.LONG_BREAK, Status.COMPLETED, 4, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an abandoned work interval does not earn a break`() {
|
||||
assertEquals(Kind.WORK, nextKindLocally(Kind.WORK, Status.ABANDONED, 3, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short break follows work mid-cycle`() {
|
||||
assertEquals(Kind.SHORT_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 1, 4))
|
||||
assertEquals(Kind.SHORT_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 3, 4))
|
||||
assertEquals(Kind.SHORT_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 5, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long break lands on the cycle boundary`() {
|
||||
assertEquals(Kind.LONG_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 4, 4))
|
||||
assertEquals(Kind.LONG_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 8, 4))
|
||||
assertEquals(Kind.LONG_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 2, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long break is never owed from yesterday`() {
|
||||
// The count resets with the day, so zero completed means no boundary.
|
||||
assertEquals(Kind.SHORT_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 0, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a nonsense cycle length does not divide by zero`() {
|
||||
assertEquals(Kind.LONG_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 3, 0))
|
||||
}
|
||||
}
|
||||
|
||||
class StrictModeTest {
|
||||
|
||||
@Test
|
||||
fun `strict mode demands a reason for abandoning work`() {
|
||||
assertTrue(abandonNeedsReason(true, Kind.WORK, null))
|
||||
assertTrue(abandonNeedsReason(true, Kind.WORK, ""))
|
||||
assertTrue(abandonNeedsReason(true, Kind.WORK, " "))
|
||||
assertFalse(abandonNeedsReason(true, Kind.WORK, "the phone rang"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `breaks and non-strict mode need no reason`() {
|
||||
assertFalse(abandonNeedsReason(true, Kind.SHORT_BREAK, null))
|
||||
assertFalse(abandonNeedsReason(true, Kind.LONG_BREAK, null))
|
||||
assertFalse(abandonNeedsReason(false, Kind.WORK, null))
|
||||
}
|
||||
}
|
||||
|
||||
class StaleIntervalTest {
|
||||
|
||||
@Test
|
||||
fun `six hours is the client-side reap threshold`() {
|
||||
assertFalse(isLocallyStale(0))
|
||||
assertFalse(isLocallyStale(STALE_INTERVAL_SECONDS))
|
||||
assertTrue(isLocallyStale(STALE_INTERVAL_SECONDS + 1))
|
||||
assertTrue(isLocallyStale(14 * 3600), "a phone offline overnight claims nothing")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One named test per row of the conflict matrix. This is the logic that decides whether
|
||||
* a difference between the phone and the server is our own replay landing, another
|
||||
* device taking over, or something worth interrupting the user about — and it is
|
||||
* impossible to exercise by hand on a device.
|
||||
*/
|
||||
class ReconcileTest {
|
||||
|
||||
private fun session(id: Long, kind: String = Kind.WORK, status: String = Status.RUNNING) =
|
||||
TimerSessionFacts(
|
||||
id = id,
|
||||
kind = kind,
|
||||
status = status,
|
||||
plannedSeconds = 1500,
|
||||
remainingSeconds = 900,
|
||||
)
|
||||
|
||||
private fun state(current: TimerSessionFacts?, enabled: Boolean = true) =
|
||||
ServerTimerState(current = current, timerEnabled = enabled)
|
||||
|
||||
@Test
|
||||
fun `no answer means keep ticking`() {
|
||||
assertEquals(
|
||||
Reconciliation.Offline,
|
||||
reconcile(null, LocalBelief(sessionId = 7L)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same interval, nothing queued, re-anchor silently`() {
|
||||
assertEquals(
|
||||
Reconciliation.Adopt,
|
||||
reconcile(state(session(7L)), LocalBelief(sessionId = 7L)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same interval with a queue keeps the optimistic value`() {
|
||||
// Re-anchoring here would make a pending +5 visibly bounce back and forth.
|
||||
assertEquals(
|
||||
Reconciliation.KeepOptimistic,
|
||||
reconcile(
|
||||
state(session(7L)),
|
||||
LocalBelief(sessionId = 7L),
|
||||
PendingOps(hasAny = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different interval is another device taking over`() {
|
||||
val result = reconcile(
|
||||
state(session(9L, kind = Kind.SHORT_BREAK)),
|
||||
LocalBelief(sessionId = 7L),
|
||||
)
|
||||
assertEquals(Kind.SHORT_BREAK, assertIs<Reconciliation.AdoptForeign>(result).kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different interval we queued ourselves is adopted quietly`() {
|
||||
assertEquals(
|
||||
Reconciliation.AdoptOwnReplay,
|
||||
reconcile(
|
||||
state(session(9L)),
|
||||
LocalBelief(sessionId = 7L),
|
||||
PendingOps(hasAny = true, hasStart = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a vanished interval we did not end is reported`() {
|
||||
assertEquals(
|
||||
Reconciliation.EndedElsewhere,
|
||||
reconcile(state(null), LocalBelief(sessionId = 7L)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a vanished interval we completed ourselves is not`() {
|
||||
assertEquals(
|
||||
Reconciliation.AdoptOwnReplay,
|
||||
reconcile(
|
||||
state(null),
|
||||
LocalBelief(sessionId = 7L),
|
||||
PendingOps(hasAny = true, hasTerminal = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pause we did not perform is reported`() {
|
||||
assertEquals(
|
||||
Reconciliation.PausedElsewhere,
|
||||
reconcile(
|
||||
state(session(7L, status = Status.PAUSED)),
|
||||
LocalBelief(sessionId = 7L, paused = false),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pause we already knew about is not news`() {
|
||||
assertEquals(
|
||||
Reconciliation.Adopt,
|
||||
reconcile(
|
||||
state(session(7L, status = Status.PAUSED)),
|
||||
LocalBelief(sessionId = 7L, paused = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cold start simply adopts whatever is running`() {
|
||||
// Local knows nothing yet; an interval on the server is not a conflict.
|
||||
assertEquals(
|
||||
Reconciliation.Adopt,
|
||||
reconcile(state(session(7L)), LocalBelief(sessionId = null)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle on both sides is quiet`() {
|
||||
assertEquals(
|
||||
Reconciliation.Adopt,
|
||||
reconcile(state(null), LocalBelief(sessionId = null)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `view-only mode purges the queue`() {
|
||||
// Every timer intent would 400 with "Timer is disabled (view-only mode)".
|
||||
assertEquals(
|
||||
Reconciliation.ViewOnlyPurge,
|
||||
reconcile(
|
||||
state(session(7L), enabled = false),
|
||||
LocalBelief(sessionId = 7L),
|
||||
PendingOps(hasAny = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `view-only wins even when idle and empty`() {
|
||||
assertEquals(
|
||||
Reconciliation.ViewOnlyPurge,
|
||||
reconcile(state(null, enabled = false), LocalBelief(sessionId = null)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package tech.kadence.core
|
||||
|
||||
import java.io.File
|
||||
import kotlin.math.roundToLong
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The design system, checked against the web app's own source.
|
||||
*
|
||||
* The brief is that every client is "the same product", and the README says the desktop
|
||||
* `Theme` singleton "carries the same tokens as the web app's `tokens.ts`". That has
|
||||
* only ever been true by hand. This test reads the actual TypeScript and fails if a
|
||||
* single channel drifts, which is the only version of that promise a build can keep.
|
||||
*
|
||||
* If `frontend/` is ever absent (a checkout of this module alone), the parity tests skip
|
||||
* rather than fail — they are a guard, not a dependency.
|
||||
*/
|
||||
class TokenParityTest {
|
||||
|
||||
private val repoRoot: File? = generateSequence(File("").absoluteFile) { it.parentFile }
|
||||
.firstOrNull { File(it, "frontend/src/tokens.ts").isFile }
|
||||
|
||||
private val tokensTs: File? = repoRoot?.let { File(it, "frontend/src/tokens.ts") }
|
||||
private val vizTs: File? = repoRoot?.let { File(it, "frontend/src/lib/viz.ts") }
|
||||
|
||||
/** `"#FAF7F2"` or `"rgba(23, 21, 15, 0.12)"` -> `0xAARRGGBB`. */
|
||||
private fun parseCss(value: String): Long {
|
||||
val hex = Regex("^#([0-9a-fA-F]{6})$").find(value.trim())
|
||||
if (hex != null) return rgb(hex.groupValues[1].toLong(16))
|
||||
|
||||
val rgba = Regex(
|
||||
"""^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9.]+)\s*\)$""",
|
||||
).find(value.trim()) ?: error("unrecognised colour: $value")
|
||||
|
||||
val (r, g, b, a) = rgba.destructured
|
||||
val packed = (r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong()
|
||||
return ((a.toDouble() * 255.0).roundToLong() shl 24) or packed
|
||||
}
|
||||
|
||||
/** Pull `name: "value",` pairs out of the named `export const` block. */
|
||||
private fun block(source: String, name: String): Map<String, String> {
|
||||
val start = source.indexOf("$name: Tokens = {")
|
||||
.takeIf { it >= 0 }
|
||||
?: source.indexOf("$name: VizPalette = {")
|
||||
require(start >= 0) { "could not find the `$name` block" }
|
||||
val open = source.indexOf('{', start)
|
||||
val close = source.indexOf("};", open)
|
||||
val body = source.substring(open + 1, close)
|
||||
return Regex("""(\w+)\s*:\s*"([^"]+)"""")
|
||||
.findAll(body)
|
||||
.associate { it.groupValues[1] to it.groupValues[2] }
|
||||
}
|
||||
|
||||
private fun sequentialOf(source: String, name: String): List<String> {
|
||||
val start = source.indexOf("$name: VizPalette = {")
|
||||
require(start >= 0)
|
||||
val seqAt = source.indexOf("sequential:", start)
|
||||
val open = source.indexOf('[', seqAt)
|
||||
val close = source.indexOf(']', open)
|
||||
return Regex(""""(#[0-9a-fA-F]{6})"""")
|
||||
.findAll(source.substring(open, close))
|
||||
.map { it.groupValues[1] }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun assertPalette(expected: Map<String, String>, actual: KadenceTokens, mode: String) {
|
||||
val ours = mapOf(
|
||||
"paper" to actual.paper,
|
||||
"surface" to actual.surface,
|
||||
"surfaceSunken" to actual.surfaceSunken,
|
||||
"ink" to actual.ink,
|
||||
"inkMuted" to actual.inkMuted,
|
||||
"inkFaint" to actual.inkFaint,
|
||||
"rule" to actual.rule,
|
||||
"ruleStrong" to actual.ruleStrong,
|
||||
"ember" to actual.ember,
|
||||
"emberSoft" to actual.emberSoft,
|
||||
"pine" to actual.pine,
|
||||
"amber" to actual.amber,
|
||||
"clay" to actual.clay,
|
||||
)
|
||||
assertEquals(
|
||||
expected.keys.sorted(),
|
||||
ours.keys.sorted(),
|
||||
"$mode: the Kotlin palette and tokens.ts must declare the same tokens",
|
||||
)
|
||||
for ((key, css) in expected) {
|
||||
assertEquals(
|
||||
parseCss(css).toString(16),
|
||||
ours.getValue(key).toString(16),
|
||||
"$mode token `$key` drifted from tokens.ts ($css)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the light palette matches tokens ts`() {
|
||||
val source = tokensTs?.readText() ?: return
|
||||
assertPalette(block(source, "light"), KADENCE_LIGHT, "light")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the dark palette matches tokens ts`() {
|
||||
val source = tokensTs?.readText() ?: return
|
||||
assertPalette(block(source, "dark"), KADENCE_DARK, "dark")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the chart palette matches viz ts`() {
|
||||
val source = vizTs?.readText() ?: return
|
||||
for ((name, ours) in listOf("LIGHT" to VIZ_LIGHT, "DARK" to VIZ_DARK)) {
|
||||
val expected = block(source, name)
|
||||
assertEquals(parseCss(expected.getValue("work")), ours.work, "$name work")
|
||||
assertEquals(
|
||||
parseCss(expected.getValue("shortBreak")), ours.shortBreak, "$name shortBreak",
|
||||
)
|
||||
assertEquals(
|
||||
parseCss(expected.getValue("longBreak")), ours.longBreak, "$name longBreak",
|
||||
)
|
||||
assertEquals(parseCss(expected.getValue("grid")), ours.grid, "$name grid")
|
||||
assertEquals(parseCss(expected.getValue("axis")), ours.axis, "$name axis")
|
||||
assertEquals(parseCss(expected.getValue("empty")), ours.empty, "$name empty")
|
||||
|
||||
val seq = sequentialOf(source, name).map(::parseCss)
|
||||
assertEquals(seq, ours.sequential, "$name sequential ramp")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the dark ramp is the light ramp reversed and shifted one step darker`() {
|
||||
// Not a plain reversal, which is the easy thing to assume. The dark ramp drops
|
||||
// the palest light step and prepends a darker blue (#104281), so that against a
|
||||
// dark ground "more" still reads as "darker" without the top step glowing.
|
||||
assertEquals(
|
||||
VIZ_LIGHT.sequential.reversed().dropLast(1),
|
||||
VIZ_DARK.sequential.drop(1),
|
||||
)
|
||||
assertEquals(rgb(0x104281), VIZ_DARK.sequential.first())
|
||||
assertEquals(6, VIZ_DARK.sequential.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the parity guard actually found the web app`() {
|
||||
// Guards against the tests above silently skipping forever.
|
||||
assertTrue(
|
||||
tokensTs?.isFile == true,
|
||||
"frontend/src/tokens.ts not found from ${File("").absolutePath} — " +
|
||||
"the parity tests would skip",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class RampStepTest {
|
||||
|
||||
@Test
|
||||
fun `nothing happened means no colour`() {
|
||||
assertEquals(null, rampStep(0, 8, VIZ_LIGHT))
|
||||
assertEquals(null, rampStep(-1, 8, VIZ_LIGHT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a met goal lands on the darkest step`() {
|
||||
assertEquals(VIZ_LIGHT.sequential.last(), rampStep(8, 8, VIZ_LIGHT))
|
||||
assertEquals(VIZ_LIGHT.sequential.last(), rampStep(16, 8, VIZ_LIGHT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steps climb with the ratio`() {
|
||||
val steps = (1..8).map { rampStep(it, 8, VIZ_LIGHT) }
|
||||
assertEquals(steps, steps.sortedBy { VIZ_LIGHT.sequential.indexOf(it) })
|
||||
assertEquals(VIZ_LIGHT.sequential[3], rampStep(4, 8, VIZ_LIGHT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero goal falls back to a goal of eight`() {
|
||||
assertEquals(rampStep(4, 8, VIZ_LIGHT), rampStep(4, 0, VIZ_LIGHT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kindColor maps every interval kind`() {
|
||||
assertEquals(VIZ_LIGHT.work, kindColor(Kind.WORK, VIZ_LIGHT))
|
||||
assertEquals(VIZ_LIGHT.shortBreak, kindColor(Kind.SHORT_BREAK, VIZ_LIGHT))
|
||||
assertEquals(VIZ_LIGHT.longBreak, kindColor(Kind.LONG_BREAK, VIZ_LIGHT))
|
||||
assertEquals(VIZ_LIGHT.shortBreak, kindColor("something_new", VIZ_LIGHT))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The system accent. You asked for the phone's own colour; these tests pin down how far
|
||||
* it reaches and what stops it producing an unreadable button.
|
||||
*/
|
||||
class AccentTest {
|
||||
|
||||
@Test
|
||||
fun `the system accent is used when Android offers one`() {
|
||||
val wallpaperBlue = rgb(0x3F5AA6)
|
||||
assertEquals(wallpaperBlue, accentArgb(wallpaperBlue, dark = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ember is the fallback when there is no dynamic colour`() {
|
||||
assertEquals(KADENCE_LIGHT.ember, accentArgb(null, dark = false))
|
||||
assertEquals(KADENCE_DARK.ember, accentArgb(null, dark = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contrast ratio is the WCAG formula`() {
|
||||
assertEquals(21.0, contrastRatio(rgb(0x000000), rgb(0xFFFFFF)), 0.01)
|
||||
assertEquals(1.0, contrastRatio(rgb(0x808080), rgb(0x808080)), 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `white sits on the brand ember, as the other clients hard-code`() {
|
||||
assertEquals(rgb(0xFFFFFF), onAccentArgb(KADENCE_LIGHT.ember))
|
||||
assertTrue(meetsBodyContrast(rgb(0xFFFFFF), KADENCE_LIGHT.ember))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pale system accent gets dark text instead of unreadable white`() {
|
||||
// Material You will happily return a pale yellow; white-on-pale is the failure
|
||||
// mode this guard exists for.
|
||||
val paleYellow = rgb(0xFFE08A)
|
||||
assertEquals(rgb(0x16140F), onAccentArgb(paleYellow))
|
||||
assertTrue(meetsBodyContrast(onAccentArgb(paleYellow), paleYellow))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whatever the accent, its label is readable`() {
|
||||
// Sweep the hue/lightness space the system could hand us and assert the chosen
|
||||
// label always clears AA for large text.
|
||||
var checked = 0
|
||||
for (r in 0..255 step 51) {
|
||||
for (g in 0..255 step 51) {
|
||||
for (b in 0..255 step 51) {
|
||||
val accent = rgb((r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong())
|
||||
val label = onAccentArgb(accent)
|
||||
assertTrue(
|
||||
meetsLargeTextContrast(label, accent),
|
||||
"label on #%06x only reaches %.2f:1".format(
|
||||
accent and 0xFFFFFF,
|
||||
contrastRatio(label, accent),
|
||||
),
|
||||
)
|
||||
checked++
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(checked > 200, "the sweep should cover the colour space")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the chart palette never follows the accent`() {
|
||||
// A chart has to stay colour-blind readable, which is a property of the
|
||||
// validated palette, not of the wallpaper.
|
||||
val wallpaper = rgb(0x3F5AA6)
|
||||
assertTrue(VIZ_LIGHT.work != wallpaper)
|
||||
assertEquals(rgb(0xEB6834), VIZ_LIGHT.work, "work keeps its validated orange")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
kotlin.code.style=official
|
||||
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,49 @@
|
||||
[versions]
|
||||
agp = "9.4.1"
|
||||
kotlin = "2.4.21"
|
||||
# compileSdk 37 because Compose 1.12 (BOM 2026.09.00) requires it; the platform is
|
||||
# published under the new minor-version scheme as `platforms;android-37.0`.
|
||||
# targetSdk stays 36 — Android 16 is what the Nothing Phone (3) runs, and opting into
|
||||
# a newer runtime's behaviour changes is a separate decision from compiling against it.
|
||||
compileSdk = "37"
|
||||
targetSdk = "36"
|
||||
minSdk = "34"
|
||||
composeBom = "2026.09.00"
|
||||
activityCompose = "1.13.0"
|
||||
navigationCompose = "2.10.2"
|
||||
lifecycle = "2.11.0"
|
||||
coroutines = "1.10.2"
|
||||
robolectric = "4.14.1"
|
||||
serialization = "1.11.0"
|
||||
okhttp = "5.5.0"
|
||||
junit = "4.13.2"
|
||||
|
||||
[libraries]
|
||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
|
||||
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3-junit4", version.ref = "okhttp" }
|
||||
|
||||
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
||||
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||
compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
|
||||
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||
compose-foundation = { module = "androidx.compose.foundation:foundation" }
|
||||
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
|
||||
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
||||
navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
||||
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,52 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "kadence-android"
|
||||
|
||||
// `core` is plain Kotlin on the JVM: the matchers, the policy clock, the outbox
|
||||
// reconciliation, the Glyph frame and the formatting. No Android, no device, no SDK —
|
||||
// which is the whole point. `./gradlew :core:test` runs the logic suite on a machine
|
||||
// that has nothing but a JDK, so almost none of this app needs a phone to verify.
|
||||
include(":core")
|
||||
|
||||
// `app` is the Android client and genuinely needs an SDK. Including it
|
||||
// unconditionally would make every Gradle invocation fail on a machine without one,
|
||||
// taking `:core:test` down with it — so it joins the build only when an SDK exists.
|
||||
val androidSdk: java.io.File? = sequenceOf(
|
||||
System.getenv("ANDROID_HOME"),
|
||||
System.getenv("ANDROID_SDK_ROOT"),
|
||||
file("local.properties")
|
||||
.takeIf { it.isFile }
|
||||
?.readLines()
|
||||
?.firstOrNull { it.startsWith("sdk.dir=") }
|
||||
?.substringAfter('=')
|
||||
?.trim(),
|
||||
).filterNotNull()
|
||||
.filter { it.isNotBlank() }
|
||||
.map { file(it) }
|
||||
.firstOrNull { it.isDirectory }
|
||||
|
||||
val appModule = file("app/build.gradle.kts")
|
||||
|
||||
if (androidSdk != null && appModule.isFile) {
|
||||
include(":app")
|
||||
} else if (androidSdk != null) {
|
||||
logger.lifecycle("SDK found, but app/ is not built out yet — :app excluded.")
|
||||
} else {
|
||||
logger.lifecycle(
|
||||
"Android SDK not found (set ANDROID_HOME or local.properties' sdk.dir) — " +
|
||||
":app is excluded from this build. :core still compiles and tests.",
|
||||
)
|
||||
}
|
||||
@@ -41,8 +41,8 @@ ScrollView {
|
||||
caption: page.metrics && page.metrics.goal_met ? "goal met" : "goal not met yet",
|
||||
accent: true },
|
||||
{ label: "Focus",
|
||||
value: page.metrics ? page.metrics.focus_minutes + "m" : "—",
|
||||
caption: page.metrics ? page.metrics.break_minutes + "m resting" : "" },
|
||||
value: page.metrics ? Theme.humanMinutes(page.metrics.focus_minutes) : "—",
|
||||
caption: page.metrics ? Theme.humanMinutes(page.metrics.break_minutes) + " resting" : "" },
|
||||
{ label: "Score",
|
||||
value: page.payload ? "" + Math.round(page.payload.score) : "—",
|
||||
caption: "out of 100" },
|
||||
|
||||
@@ -70,4 +70,15 @@ QtObject {
|
||||
if (kind === "long_break") return "Long break"
|
||||
return "Focus"
|
||||
}
|
||||
|
||||
// Mirrors the web app's humanMinutes() in lib/format.ts, so "125m" reads
|
||||
// as "2h 5m" here too.
|
||||
function humanMinutes(minutes) {
|
||||
var rounded = Math.round(minutes)
|
||||
var hours = Math.floor(rounded / 60)
|
||||
var rest = rounded % 60
|
||||
if (hours && rest) return hours + "h " + rest + "m"
|
||||
if (hours) return hours + "h"
|
||||
return rest + "m"
|
||||
}
|
||||
}
|
||||
@@ -213,10 +213,10 @@ ScrollView {
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
text: controller.stats.today
|
||||
? controller.stats.today.focus_minutes + "m focused · "
|
||||
+ controller.stats.today.break_minutes + "m rested"
|
||||
? Theme.humanMinutes(controller.stats.today.focus_minutes) + " focused · "
|
||||
+ Theme.humanMinutes(controller.stats.today.break_minutes) + " rested"
|
||||
+ (controller.stats.today.longest_stretch_minutes > 0
|
||||
? " · longest run " + controller.stats.today.longest_stretch_minutes + "m" : "")
|
||||
? " · longest run " + Theme.humanMinutes(controller.stats.today.longest_stretch_minutes) : "")
|
||||
: ""
|
||||
color: Theme.muted
|
||||
font.pixelSize: 12
|
||||
@@ -280,7 +280,7 @@ ScrollView {
|
||||
Repeater {
|
||||
model: [
|
||||
{ label: "Focus today",
|
||||
value: controller.stats.today ? controller.stats.today.focus_minutes + "m" : "—",
|
||||
value: controller.stats.today ? Theme.humanMinutes(controller.stats.today.focus_minutes) : "—",
|
||||
caption: controller.completedToday + " intervals" },
|
||||
{ label: "Interruptions",
|
||||
value: controller.stats.today ? "" + controller.stats.today.interruptions : "—",
|
||||
|
||||
Reference in new issue
Block a user