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/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
tsconfig.tsbuildinfo
|
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)
|
helper-install: ## Install the privileged blocking helper (asks for sudo)
|
||||||
sudo ./desktop/helper/install.sh
|
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
|
report: ## Build and send today's report right now
|
||||||
cd backend && PYTHONPATH=$$PWD .venv/bin/python -m app.cli report
|
cd backend && PYTHONPATH=$$PWD .venv/bin/python -m app.cli report
|
||||||
|
|
||||||
set-password: ## Set/rotate the web login password (asks for a confirm)
|
set-password: ## Set/rotate the web login password (asks for a confirm)
|
||||||
docker compose exec app python -m app.cli set-password
|
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",
|
caption: page.metrics && page.metrics.goal_met ? "goal met" : "goal not met yet",
|
||||||
accent: true },
|
accent: true },
|
||||||
{ label: "Focus",
|
{ label: "Focus",
|
||||||
value: page.metrics ? page.metrics.focus_minutes + "m" : "—",
|
value: page.metrics ? Theme.humanMinutes(page.metrics.focus_minutes) : "—",
|
||||||
caption: page.metrics ? page.metrics.break_minutes + "m resting" : "" },
|
caption: page.metrics ? Theme.humanMinutes(page.metrics.break_minutes) + " resting" : "" },
|
||||||
{ label: "Score",
|
{ label: "Score",
|
||||||
value: page.payload ? "" + Math.round(page.payload.score) : "—",
|
value: page.payload ? "" + Math.round(page.payload.score) : "—",
|
||||||
caption: "out of 100" },
|
caption: "out of 100" },
|
||||||
|
|||||||
@@ -70,4 +70,15 @@ QtObject {
|
|||||||
if (kind === "long_break") return "Long break"
|
if (kind === "long_break") return "Long break"
|
||||||
return "Focus"
|
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 {
|
Label {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
text: controller.stats.today
|
text: controller.stats.today
|
||||||
? controller.stats.today.focus_minutes + "m focused · "
|
? Theme.humanMinutes(controller.stats.today.focus_minutes) + " focused · "
|
||||||
+ controller.stats.today.break_minutes + "m rested"
|
+ Theme.humanMinutes(controller.stats.today.break_minutes) + " rested"
|
||||||
+ (controller.stats.today.longest_stretch_minutes > 0
|
+ (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
|
color: Theme.muted
|
||||||
font.pixelSize: 12
|
font.pixelSize: 12
|
||||||
@@ -280,7 +280,7 @@ ScrollView {
|
|||||||
Repeater {
|
Repeater {
|
||||||
model: [
|
model: [
|
||||||
{ label: "Focus today",
|
{ 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" },
|
caption: controller.completedToday + " intervals" },
|
||||||
{ label: "Interruptions",
|
{ label: "Interruptions",
|
||||||
value: controller.stats.today ? "" + controller.stats.today.interruptions : "—",
|
value: controller.stats.today ? "" + controller.stats.today.interruptions : "—",
|
||||||
|
|||||||
Reference in new issue
Block a user