diff --git a/.gitignore b/.gitignore index e6e539b..7d3f46f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,11 @@ backend/static/ .vscode/ .DS_Store tsconfig.tsbuildinfo +# Android +android/.gradle/ +android/build/ +android/*/build/ +android/local.properties +android/app/libs/*.aar +android/glyph/libs/*.aar +*.keystore diff --git a/Makefile b/Makefile index 17530d6..51eb246 100644 --- a/Makefile +++ b/Makefile @@ -36,10 +36,22 @@ desktop-install: ## Install the launcher entry and icon for your user helper-install: ## Install the privileged blocking helper (asks for sudo) sudo ./desktop/helper/install.sh +android-test: ## Run every Android test (no device, no backend needed) + cd android && ./gradlew :core:test :app:testDebugUnitTest + +android-test-core: ## Run just the pure-logic suite (needs only a JDK) + cd android && ./gradlew :core:test + +android-build: ## Build the debug APK (needs the Android SDK) + cd android && ./gradlew :app:assembleDebug + +android-install: ## Install the debug APK on the connected phone + cd android && ./gradlew :app:installDebug + report: ## Build and send today's report right now cd backend && PYTHONPATH=$$PWD .venv/bin/python -m app.cli report set-password: ## Set/rotate the web login password (asks for a confirm) docker compose exec app python -m app.cli set-password -.PHONY: help up down logs dev test backend-venv web-build desktop desktop-install helper-install report set-password +.PHONY: help up down logs dev test backend-venv web-build desktop desktop-install helper-install android-test android-test-core android-build android-install report set-password diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..9d8f6c1 --- /dev/null +++ b/android/README.md @@ -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://:8000/api/health` is the first thing to check if onboarding cannot reach +the backend. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..5f120b0 --- /dev/null +++ b/android/app/build.gradle.kts @@ -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) +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1f01607 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt b/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt new file mode 100644 index 0000000..5f53d9f --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt @@ -0,0 +1,5 @@ +package tech.kadence.android + +import android.app.Application + +class KadenceApplication : Application() diff --git a/android/app/src/main/kotlin/tech/kadence/android/MainActivity.kt b/android/app/src/main/kotlin/tech/kadence/android/MainActivity.kt new file mode 100644 index 0000000..14ace55 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/MainActivity.kt @@ -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, + ) + } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt b/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt new file mode 100644 index 0000000..89bd5cb --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt @@ -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 = 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 = 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 = emptyList(), + val counts: Map = emptyMap(), + val sources: List = 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 = 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 = emptyList(), + /** Likewise empty when inactive. */ + val apps: List = emptyList(), + val unexpandable: List = 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 = emptyList(), + @SerialName("all_apps") val allApps: List = 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 = emptyList(), + val events: List = 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 = emptyList(), + @SerialName("peak_hour") val peakHour: Int? = null, + @SerialName("tasks_completed") val tasksCompleted: List = emptyList(), + @SerialName("tasks_completed_count") val tasksCompletedCount: Int = 0, + @SerialName("time_by_task") val timeByTask: List = emptyList(), + @SerialName("blocked_attempts") val blockedAttempts: Int = 0, + @SerialName("blocked_top") val blockedTop: List = emptyList(), + val sessions: List = 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 = emptyList(), + @SerialName("fun_facts") val funFacts: List = emptyList(), + val verdict: String = "", + @SerialName("open_tasks") val openTasks: Map = 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 = emptyList(), + val heatmap: List = 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 = emptyList(), + val metrics: DayMetricsDto = DayMetricsDto(), +) diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/net/ApiError.kt b/android/app/src/main/kotlin/tech/kadence/android/data/net/ApiError.kt new file mode 100644 index 0000000..0fc52cd --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/data/net/ApiError.kt @@ -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 { + data class Ok(val value: T) : ApiResult + + /** A 4xx/5xx the server explained. Never retried blindly. */ + data class HttpError(val status: Int, val detail: String) : ApiResult + + /** The session or token is no longer good; the UI drops to the login gate. */ + data class Unauthorized(val detail: String) : ApiResult + + /** + * 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 +} + +/** 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 +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/net/Json.kt b/android/app/src/main/kotlin/tech/kadence/android/data/net/Json.kt new file mode 100644 index 0000000..8f3bf24 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/data/net/Json.kt @@ -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 +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/net/KadenceApi.kt b/android/app/src/main/kotlin/tech/kadence/android/data/net/KadenceApi.kt new file mode 100644 index 0000000..b8c2ab2 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/data/net/KadenceApi.kt @@ -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>() + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + 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 = + 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 = 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 get(path: String): ApiResult = decode(request("GET", path)) + + suspend inline fun post(path: String, body: String? = null): ApiResult = + decode(request("POST", path, body)) + + suspend inline fun put(path: String, body: String): ApiResult = + decode(request("PUT", path, body)) + + suspend inline fun patch(path: String, body: String): ApiResult = + decode(request("PATCH", path, body)) + + suspend inline fun delete(path: String): ApiResult = + decode(request("DELETE", path)) + + /** Decode a successful body, turning a malformed payload into an HttpError. */ + inline fun decode(result: ApiResult): ApiResult = when (result) { + is ApiResult.Ok -> runCatching { + ApiResult.Ok(KadenceJson.decodeFromString(result.value)) + }.getOrElse { ApiResult.HttpError(200, "unreadable response: ${it.message}") } + is ApiResult.HttpError -> result + is ApiResult.Unauthorized -> result + is ApiResult.NetworkError -> result + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/components/Primitives.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/components/Primitives.kt new file mode 100644 index 0000000..070167d --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/components/Primitives.kt @@ -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, + ) + } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/components/TabularFigures.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/components/TabularFigures.kt new file mode 100644 index 0000000..0bf4194 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/components/TabularFigures.kt @@ -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) + } + } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/components/TimerDial.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/components/TimerDial.kt new file mode 100644 index 0000000..92a58cf --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/components/TimerDial.kt @@ -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, + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/theme/KadenceTheme.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/theme/KadenceTheme.kt new file mode 100644 index 0000000..98ab4bc --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/theme/KadenceTheme.kt @@ -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, + 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, + ) + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/theme/KadenceType.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/theme/KadenceType.kt new file mode 100644 index 0000000..fae0954 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/theme/KadenceType.kt @@ -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, + ), +) diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..a4db96a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/res/font/fraunces.ttf b/android/app/src/main/res/font/fraunces.ttf new file mode 100644 index 0000000..8210f94 Binary files /dev/null and b/android/app/src/main/res/font/fraunces.ttf differ diff --git a/android/app/src/main/res/font/inter.ttf b/android/app/src/main/res/font/inter.ttf new file mode 100644 index 0000000..047c92f Binary files /dev/null and b/android/app/src/main/res/font/inter.ttf differ diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..f103301 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/raw/ofl_fraunces.txt b/android/app/src/main/res/raw/ofl_fraunces.txt new file mode 100644 index 0000000..22fa7ce --- /dev/null +++ b/android/app/src/main/res/raw/ofl_fraunces.txt @@ -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. diff --git a/android/app/src/main/res/raw/ofl_inter.txt b/android/app/src/main/res/raw/ofl_inter.txt new file mode 100644 index 0000000..21f6aff --- /dev/null +++ b/android/app/src/main/res/raw/ofl_inter.txt @@ -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. diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..4fbc4ef --- /dev/null +++ b/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + + #121110 + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..42b11b6 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + + #FAF7F2 + + #FAF7F2 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8e87789 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Kadence + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..a49d7b2 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android/app/src/test/kotlin/tech/kadence/android/DtoToleranceTest.kt b/android/app/src/test/kotlin/tech/kadence/android/DtoToleranceTest.kt new file mode 100644 index 0000000..a4a6c61 --- /dev/null +++ b/android/app/src/test/kotlin/tech/kadence/android/DtoToleranceTest.kt @@ -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 decode(name: String): T = + KadenceJson.decodeFromString(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("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("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("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("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("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("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("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("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("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("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("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("{}").current) + assertEquals(8, KadenceJson.decodeFromString("{}").goal) + assertTrue(KadenceJson.decodeFromString("{}").tasks.isEmpty()) + assertTrue(!KadenceJson.decodeFromString("{}").active) + assertEquals(0.0, KadenceJson.decodeFromString("{}").report.score) + assertEquals(0, KadenceJson.decodeFromString("{}").range.size) + assertEquals(0, KadenceJson.decodeFromString("{}").total) + assertTrue(!KadenceJson.decodeFromString("{}").authenticated) + } + + @Test + fun `unknown keys are ignored everywhere`() { + val grown = withUnknownKeys(fixture("timer_state_running.json")) + assertEquals(412L, KadenceJson.decodeFromString(grown).current?.id) + + val policy = withUnknownKeys(fixture("blocker_policy_active.json")) + assertTrue(KadenceJson.decodeFromString(policy).active) + + val tasks = withUnknownKeys(fixture("tasks.json")) + assertEquals(3, KadenceJson.decodeFromString(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(nulled) + assertNull(state.current) + assertEquals(8, state.goal, "the default survives a null") + assertEquals("work", state.nextKind) + assertTrue(state.timerEnabled) + + val policy = KadenceJson.decodeFromString( + 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(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") + } + } +} diff --git a/android/app/src/test/kotlin/tech/kadence/android/KadenceApiTest.kt b/android/app/src/test/kotlin/tech/kadence/android/KadenceApiTest.kt new file mode 100644 index 0000000..ce82c17 --- /dev/null +++ b/android/app/src/test/kotlin/tech/kadence/android/KadenceApiTest.kt @@ -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("/api/timer/state") + assertIs>(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("/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>("/api/auth/login", """{"username":"timka","password":"x"}""") + server.takeRequest() + + api.get("/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("/api/timer/state") + + assertIs(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>("/api/auth/login", """{"username":"a"}""") + + assertIs(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("/api/timer/pause") + assertIs(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("/api/timer/start", """{"minutes":9999}""") + assertIs(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("Bad Gateway").build(), + ) + + val result = api.get("/api/timer/state") + assertIs(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("/api/timer/state") + assertIs(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("/api/timer/state") + assertIs(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("/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("/api/timer/state") + + assertEquals("/api/timer/state", server.takeRequest().target) + } +} diff --git a/android/app/src/test/kotlin/tech/kadence/android/ThemeTest.kt b/android/app/src/test/kotlin/tech/kadence/android/ThemeTest.kt new file mode 100644 index 0000000..6232cde --- /dev/null +++ b/android/app/src/test/kotlin/tech/kadence/android/ThemeTest.kt @@ -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", + ) + } +} diff --git a/android/app/src/test/resources/fixtures/auth_me.json b/android/app/src/test/resources/fixtures/auth_me.json new file mode 100644 index 0000000..2adb984 --- /dev/null +++ b/android/app/src/test/resources/fixtures/auth_me.json @@ -0,0 +1,8 @@ +{ + "authenticated": true, + "login_required": true, + "username": "timka", + "password_login_enabled": true, + "telegram_login_enabled": false, + "telegram_bot_username": "" +} diff --git a/android/app/src/test/resources/fixtures/block_events.json b/android/app/src/test/resources/fixtures/block_events.json new file mode 100644 index 0000000..cb3d0b7 --- /dev/null +++ b/android/app/src/test/resources/fixtures/block_events.json @@ -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)"} + ] +} diff --git a/android/app/src/test/resources/fixtures/blocker_policy_active.json b/android/app/src/test/resources/fixtures/blocker_policy_active.json new file mode 100644 index 0000000..89f6a4f --- /dev/null +++ b/android/app/src/test/resources/fixtures/blocker_policy_active.json @@ -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*"] +} diff --git a/android/app/src/test/resources/fixtures/blocker_policy_inactive.json b/android/app/src/test/resources/fixtures/blocker_policy_inactive.json new file mode 100644 index 0000000..d35db0a --- /dev/null +++ b/android/app/src/test/resources/fixtures/blocker_policy_inactive.json @@ -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*"] +} diff --git a/android/app/src/test/resources/fixtures/daily_report.json b/android/app/src/test/resources/fixtures/daily_report.json new file mode 100644 index 0000000..e3afe6e --- /dev/null +++ b/android/app/src/test/resources/fixtures/daily_report.json @@ -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" +} diff --git a/android/app/src/test/resources/fixtures/stats_summary.json b/android/app/src/test/resources/fixtures/stats_summary.json new file mode 100644 index 0000000..a86c8e2 --- /dev/null +++ b/android/app/src/test/resources/fixtures/stats_summary.json @@ -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} + ] +} diff --git a/android/app/src/test/resources/fixtures/tasks.json b/android/app/src/test/resources/fixtures/tasks.json new file mode 100644 index 0000000..11f95c2 --- /dev/null +++ b/android/app/src/test/resources/fixtures/tasks.json @@ -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"] +} diff --git a/android/app/src/test/resources/fixtures/timer_complete_reply.json b/android/app/src/test/resources/fixtures/timer_complete_reply.json new file mode 100644 index 0000000..42c7483 --- /dev/null +++ b/android/app/src/test/resources/fixtures/timer_complete_reply.json @@ -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 +} diff --git a/android/app/src/test/resources/fixtures/timer_state_idle.json b/android/app/src/test/resources/fixtures/timer_state_idle.json new file mode 100644 index 0000000..0d759c4 --- /dev/null +++ b/android/app/src/test/resources/fixtures/timer_state_idle.json @@ -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 +} diff --git a/android/app/src/test/resources/fixtures/timer_state_running.json b/android/app/src/test/resources/fixtures/timer_state_running.json new file mode 100644 index 0000000..588b01f --- /dev/null +++ b/android/app/src/test/resources/fixtures/timer_state_running.json @@ -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 +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..8abdaf7 --- /dev/null +++ b/android/build.gradle.kts @@ -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 +} diff --git a/android/core/build.gradle.kts b/android/core/build.gradle.kts new file mode 100644 index 0000000..a7491ef --- /dev/null +++ b/android/core/build.gradle.kts @@ -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") + } +} diff --git a/android/core/src/main/kotlin/tech/kadence/core/Blocker.kt b/android/core/src/main/kotlin/tech/kadence/core/Blocker.kt new file mode 100644 index 0000000..7cf7f45 --- /dev/null +++ b/android/core/src/main/kotlin/tech/kadence/core/Blocker.kt @@ -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 = 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 = emptyList(), + val allowedDomains: List = emptyList(), + val subdomainExpansions: List = DEFAULT_SUBDOMAINS, + val blockedApps: List = emptyList(), + val killBlockedApps: Boolean = true, + val schedule: List = 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): Pair, String?> { + val entry = raw.trim().lowercase().trimStart('.') + if (entry.isEmpty()) return emptyList() to null + + if (entry.startsWith("*.")) { + val base = entry.removePrefix("*.") + if (base.isEmpty() || '*' in base) return emptyList() to entry + return (listOf(base) + subdomains.map { "$it.$base" }) to null + } + + if ('*' in entry) return emptyList() 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): Boolean = + allowMasks.any { globMatches(it, host) } + +/** Normalised, de-duplicated allow masks, the way `expand_domains()` prepares them. */ +fun allowMasksOf(rules: BlockerRules): List = rules.allowedDomains + .filter { it.isNotBlank() } + .map { it.trim().lowercase().trimStart('.') } + +private fun subdomainsOf(rules: BlockerRules): List = 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 = 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 { + val allowMasks = allowMasksOf(rules) + val subdomains = subdomainsOf(rules) + val origin = LinkedHashMap() + 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 { + val subdomains = subdomainsOf(rules) + val leftovers = LinkedHashSet() + 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() } diff --git a/android/core/src/main/kotlin/tech/kadence/core/Format.kt b/android/core/src/main/kotlin/tech/kadence/core/Format.kt new file mode 100644 index 0000000..8017156 --- /dev/null +++ b/android/core/src/main/kotlin/tech/kadence/core/Format.kt @@ -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 = 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 = 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 = 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') diff --git a/android/core/src/main/kotlin/tech/kadence/core/Glob.kt b/android/core/src/main/kotlin/tech/kadence/core/Glob.kt new file mode 100644 index 0000000..842ebdd --- /dev/null +++ b/android/core/src/main/kotlin/tech/kadence/core/Glob.kt @@ -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() diff --git a/android/core/src/main/kotlin/tech/kadence/core/GlyphFrame.kt b/android/core/src/main/kotlin/tech/kadence/core/GlyphFrame.kt new file mode 100644 index 0000000..594e911 --- /dev/null +++ b/android/core/src/main/kotlin/tech/kadence/core/GlyphFrame.kt @@ -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> = 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') + } +} diff --git a/android/core/src/main/kotlin/tech/kadence/core/Matchers.kt b/android/core/src/main/kotlin/tech/kadence/core/Matchers.kt new file mode 100644 index 0000000..6227bdd --- /dev/null +++ b/android/core/src/main/kotlin/tech/kadence/core/Matchers.kt @@ -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 = expandDomains(rules), +) { + private val exact: Set = expandedHosts.map { it.trim().lowercase() }.toSet() + private val allowMasks: List = allowMasksOf(rules) + private val rawEntries: List = 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 = 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 = unexpandableMasks(rules) +} + +/** Matches installed apps against `blocker.blocked_apps`. */ +class AppMatcher(private val patterns: List) { + + /** + * @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 + } + } + } +} diff --git a/android/core/src/main/kotlin/tech/kadence/core/Outbox.kt b/android/core/src/main/kotlin/tech/kadence/core/Outbox.kt new file mode 100644 index 0000000..c22d598 --- /dev/null +++ b/android/core/src/main/kotlin/tech/kadence/core/Outbox.kt @@ -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, incoming: OutboxEntry): List { + 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): 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): Pair, List> { + 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() + 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 +} diff --git a/android/core/src/main/kotlin/tech/kadence/core/TimeWindow.kt b/android/core/src/main/kotlin/tech/kadence/core/TimeWindow.kt new file mode 100644 index 0000000..e7c2526 --- /dev/null +++ b/android/core/src/main/kotlin/tech/kadence/core/TimeWindow.kt @@ -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) +} diff --git a/android/core/src/main/kotlin/tech/kadence/core/Timer.kt b/android/core/src/main/kotlin/tech/kadence/core/Timer.kt new file mode 100644 index 0000000..fa1690c --- /dev/null +++ b/android/core/src/main/kotlin/tech/kadence/core/Timer.kt @@ -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 = 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 + } +} diff --git a/android/core/src/main/kotlin/tech/kadence/core/Tokens.kt b/android/core/src/main/kotlin/tech/kadence/core/Tokens.kt new file mode 100644 index 0000000..e82fb17 --- /dev/null +++ b/android/core/src/main/kotlin/tech/kadence/core/Tokens.kt @@ -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, + 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 diff --git a/android/core/src/test/kotlin/tech/kadence/core/BlockerTest.kt b/android/core/src/test/kotlin/tech/kadence/core/BlockerTest.kt new file mode 100644 index 0000000..caa3125 --- /dev/null +++ b/android/core/src/test/kotlin/tech/kadence/core/BlockerTest.kt @@ -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() to "*gram*", expandEntry("*gram*", subs)) + assertEquals(emptyList() to "*.", expandEntry("*.", subs)) + assertEquals(emptyList() to "**", expandEntry("**", subs)) + assertEquals(emptyList() to "*.a*.com", expandEntry("*.a*.com", subs)) + } + + @Test + fun `an empty entry is neither expanded nor reported`() { + assertEquals(emptyList() to null, expandEntry("", subs)) + assertEquals(emptyList() to null, expandEntry(" ", subs)) + assertEquals(emptyList() 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("")) + } +} diff --git a/android/core/src/test/kotlin/tech/kadence/core/FormatTest.kt b/android/core/src/test/kotlin/tech/kadence/core/FormatTest.kt new file mode 100644 index 0000000..d63c79c --- /dev/null +++ b/android/core/src/test/kotlin/tech/kadence/core/FormatTest.kt @@ -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")) + } +} diff --git a/android/core/src/test/kotlin/tech/kadence/core/GlobTest.kt b/android/core/src/test/kotlin/tech/kadence/core/GlobTest.kt new file mode 100644 index 0000000..f767ff9 --- /dev/null +++ b/android/core/src/test/kotlin/tech/kadence/core/GlobTest.kt @@ -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), + ) + } +} diff --git a/android/core/src/test/kotlin/tech/kadence/core/GlyphFrameTest.kt b/android/core/src/test/kotlin/tech/kadence/core/GlyphFrameTest.kt new file mode 100644 index 0000000..7e57454 --- /dev/null +++ b/android/core/src/test/kotlin/tech/kadence/core/GlyphFrameTest.kt @@ -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 = + 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) + } +} diff --git a/android/core/src/test/kotlin/tech/kadence/core/MatchersTest.kt b/android/core/src/test/kotlin/tech/kadence/core/MatchersTest.kt new file mode 100644 index 0000000..0df56aa --- /dev/null +++ b/android/core/src/test/kotlin/tech/kadence/core/MatchersTest.kt @@ -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 = emptyList(), + allowed: List = emptyList(), + subs: List = 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(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(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(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(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(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(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) + } +} diff --git a/android/core/src/test/kotlin/tech/kadence/core/OutboxTest.kt b/android/core/src/test/kotlin/tech/kadence/core/OutboxTest.kt new file mode 100644 index 0000000..9c2269b --- /dev/null +++ b/android/core/src/test/kotlin/tech/kadence/core/OutboxTest.kt @@ -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 = + 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(replayOutcomeFor(1)) + assertEquals(2, retry.attempt) + assertIs(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)) + } +} diff --git a/android/core/src/test/kotlin/tech/kadence/core/TimeWindowTest.kt b/android/core/src/test/kotlin/tech/kadence/core/TimeWindowTest.kt new file mode 100644 index 0000000..8f2b72e --- /dev/null +++ b/android/core/src/test/kotlin/tech/kadence/core/TimeWindowTest.kt @@ -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")) + } +} diff --git a/android/core/src/test/kotlin/tech/kadence/core/TimerTest.kt b/android/core/src/test/kotlin/tech/kadence/core/TimerTest.kt new file mode 100644 index 0000000..8551854 --- /dev/null +++ b/android/core/src/test/kotlin/tech/kadence/core/TimerTest.kt @@ -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(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)), + ) + } +} diff --git a/android/core/src/test/kotlin/tech/kadence/core/TokenParityTest.kt b/android/core/src/test/kotlin/tech/kadence/core/TokenParityTest.kt new file mode 100644 index 0000000..e6c2837 --- /dev/null +++ b/android/core/src/test/kotlin/tech/kadence/core/TokenParityTest.kt @@ -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 { + 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 { + 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, 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") + } +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..6f92b49 --- /dev/null +++ b/android/gradle.properties @@ -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 diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..1b9025a --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -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" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7e7d24f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/android/gradlew @@ -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" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/android/gradlew.bat @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..d146651 --- /dev/null +++ b/android/settings.gradle.kts @@ -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.", + ) +} diff --git a/desktop/kadence_desktop/qml/ReportPage.qml b/desktop/kadence_desktop/qml/ReportPage.qml index 461e098..e1b95d3 100644 --- a/desktop/kadence_desktop/qml/ReportPage.qml +++ b/desktop/kadence_desktop/qml/ReportPage.qml @@ -41,8 +41,8 @@ ScrollView { caption: page.metrics && page.metrics.goal_met ? "goal met" : "goal not met yet", accent: true }, { label: "Focus", - value: page.metrics ? page.metrics.focus_minutes + "m" : "—", - caption: page.metrics ? page.metrics.break_minutes + "m resting" : "" }, + value: page.metrics ? Theme.humanMinutes(page.metrics.focus_minutes) : "—", + caption: page.metrics ? Theme.humanMinutes(page.metrics.break_minutes) + " resting" : "" }, { label: "Score", value: page.payload ? "" + Math.round(page.payload.score) : "—", caption: "out of 100" }, diff --git a/desktop/kadence_desktop/qml/Theme.qml b/desktop/kadence_desktop/qml/Theme.qml index be42ee7..a0969a1 100644 --- a/desktop/kadence_desktop/qml/Theme.qml +++ b/desktop/kadence_desktop/qml/Theme.qml @@ -70,4 +70,15 @@ QtObject { if (kind === "long_break") return "Long break" return "Focus" } + + // Mirrors the web app's humanMinutes() in lib/format.ts, so "125m" reads + // as "2h 5m" here too. + function humanMinutes(minutes) { + var rounded = Math.round(minutes) + var hours = Math.floor(rounded / 60) + var rest = rounded % 60 + if (hours && rest) return hours + "h " + rest + "m" + if (hours) return hours + "h" + return rest + "m" + } } diff --git a/desktop/kadence_desktop/qml/TimerPage.qml b/desktop/kadence_desktop/qml/TimerPage.qml index e1fd067..99c7ffe 100644 --- a/desktop/kadence_desktop/qml/TimerPage.qml +++ b/desktop/kadence_desktop/qml/TimerPage.qml @@ -213,10 +213,10 @@ ScrollView { Label { Layout.fillWidth: true text: controller.stats.today - ? controller.stats.today.focus_minutes + "m focused · " - + controller.stats.today.break_minutes + "m rested" + ? Theme.humanMinutes(controller.stats.today.focus_minutes) + " focused · " + + Theme.humanMinutes(controller.stats.today.break_minutes) + " rested" + (controller.stats.today.longest_stretch_minutes > 0 - ? " · longest run " + controller.stats.today.longest_stretch_minutes + "m" : "") + ? " · longest run " + Theme.humanMinutes(controller.stats.today.longest_stretch_minutes) : "") : "" color: Theme.muted font.pixelSize: 12 @@ -280,7 +280,7 @@ ScrollView { Repeater { model: [ { label: "Focus today", - value: controller.stats.today ? controller.stats.today.focus_minutes + "m" : "—", + value: controller.stats.today ? Theme.humanMinutes(controller.stats.today.focus_minutes) : "—", caption: controller.completedToday + " intervals" }, { label: "Interruptions", value: controller.stats.today ? "" + controller.stats.today.interruptions : "—",