android: the real client — backend, notifications, Live Update, Glyph

The app module was a design gallery: tokens, type and a dial, with no backend,
no notification and nothing driving the rear matrix. This is the rest of it.

**One engine, three views.** `TimerEngine` lives on the Application, not in a
ViewModel, because the Compose screen, the foreground service's notification
and the Glyph must show the *same* second. Two engines polling the same backend
drift apart within seconds and the user sees a notification disagreeing with
the dial. It polls, ticks from a monotonic anchor, and puts every reply through
`:core`'s `reconcile`.

`pomodoro.state()` does not finish an interval whose time is up — it reports
`remaining_seconds = 0` and waits for a client to call `complete`. The tick
loop does, behind the same re-entry guard `desktop/controller.py` uses, since
without one a 1 Hz tick at zero fires `complete` every second until the reply
lands.

**Notifications.** Two channels, because the running interval must be silent
and its *end* must not; one channel cannot be both, and a user who silences a
buzzing countdown has silenced the only part that mattered. The ongoing one is
an Android 16 Live Update: promotion needs `setOngoing` + `setRequestPromotedOngoing`
+ a `ProgressStyle`, and missing any one renders it as an ordinary notification
with no error and no log. Its segments come from `:core`'s `dialTickCount`, so
the status bar, the dial and the Glyph ring agree on what one tick is worth.

**Glyph.** `GlyphMatrixManager.init()` binds `com.nothing.thirdparty/.GlyphService`
and *discards* the boolean `bindService` returns — so on Android 11+ a missing
`<queries>` entry makes the package invisible, the bind silently fail, and the
matrix stay dark with no exception, no log and `onServiceConnected` never
arriving. That was the whole of "the glyph does not work". The manifest now
declares it and `MatrixGlyphDisplay` checks visibility itself rather than
leaving the next person with the same dead end.

The SDK's licence forbids redistribution (clause 2.1(c)), so the .aar stays
git-ignored and `fetch-glyph-sdk.sh` downloads it. Without it a second source
set compiles a no-op display: the app builds, installs and runs with an inert
rear panel, which is also the correct behaviour on any phone that is not a
Nothing.

Verified on the device (Phone (3), Android 16), debug and release: onboarding
against kadence.timka.su, the Live Update with `shortCriticalText` and a 600s
progress track, the matrix accepting 625-dot frames, tasks opening in the Jira
app, and R8 leaving the DTOs decodable. 225 tests, none needing a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
timka123andClaude Opus 5 committed 2026-10-19 16:05:13 +03:00
1 parent 5259e027c6
commit a7148b69c6
32 files changed
+4118 -162

No files matched your search

+85 -2
View File
@@ -6,6 +6,19 @@ plugins {
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
} }
// ---- the Glyph Matrix SDK, which may or may not be here --------------------
// Nothing's .aar is not on Maven and its licence forbids redistribution, so it
// cannot be committed. `./fetch-glyph-sdk.sh` downloads it.
//
// Rather than failing the build without it, the rear display is chosen at
// configuration time: exactly one of two source sets is compiled, each providing
// `newGlyphDisplay()`. With the .aar the real one binds `GlyphMatrixManager`;
// without it the no-op returns a display that accepts frames and drops them. The
// rest of the app does not know which it got, and `:core` — where every frame is
// actually computed — never depends on either.
val glyphAar = layout.projectDirectory.file("libs/glyph-matrix-sdk-2.0.aar").asFile
val hasGlyphSdk = glyphAar.isFile
android { android {
namespace = "tech.kadence.android" namespace = "tech.kadence.android"
compileSdk = libs.versions.compileSdk.get().toInt() compileSdk = libs.versions.compileSdk.get().toInt()
@@ -15,8 +28,43 @@ android {
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("boolean", "HAS_GLYPH_SDK", hasGlyphSdk.toString())
// What onboarding pre-fills. Only a starting guess — the field is editable,
// and `KadencePrefs.normalizeBaseUrl` copes with a LAN address typed instead.
// Both build types get the real deployment rather than an emulator loopback,
// because this app's backend is a real host and `10.0.2.2` is only ever right
// inside an emulator.
buildConfigField("String", "DEFAULT_BACKEND", "\"https://kadence.timka.su\"")
}
sourceSets {
getByName("main") {
kotlin.srcDir(if (hasGlyphSdk) "src/glyph/kotlin" else "src/noglyph/kotlin")
}
}
signingConfigs {
// Release signing comes from ~/.gradle/gradle.properties or the environment,
// never from the repo. Absent them, `assembleRelease` falls back to the debug
// key: it still produces an installable APK for one's own phone, which is the
// only distribution this app has.
create("release") {
val storePath = (project.findProperty("KADENCE_KEYSTORE") as String?)
?: System.getenv("KADENCE_KEYSTORE")
if (storePath != null && file(storePath).isFile) {
storeFile = file(storePath)
storePassword = (project.findProperty("KADENCE_KEYSTORE_PASSWORD") as String?)
?: System.getenv("KADENCE_KEYSTORE_PASSWORD")
keyAlias = (project.findProperty("KADENCE_KEY_ALIAS") as String?)
?: System.getenv("KADENCE_KEY_ALIAS") ?: "kadence"
keyPassword = (project.findProperty("KADENCE_KEY_PASSWORD") as String?)
?: System.getenv("KADENCE_KEY_PASSWORD")
}
}
} }
buildTypes { buildTypes {
@@ -25,7 +73,17 @@ android {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
} }
release { release {
isMinifyEnabled = false // R8 on, because an always-running foreground service wants the smaller,
// faster build. The keep rules it needs are in proguard-rules.pro.
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.getByName("release").takeIf {
it.storeFile != null
} ?: signingConfigs.getByName("debug")
} }
} }
@@ -41,6 +99,13 @@ android {
testOptions { testOptions {
unitTests.isIncludeAndroidResources = true unitTests.isIncludeAndroidResources = true
// `android.util.Log` is one of the stubbed-out android.jar methods that throws
// "not mocked" on the JVM. Most of these tests are deliberately *not* under
// Robolectric — `KadenceApiTest` is pure okhttp + MockWebServer and should stay
// that way — so a single debug log line inside the HTTP client would otherwise
// fail six unrelated tests. Returning defaults makes Log a no-op there.
unitTests.isReturnDefaultValues = true
} }
packaging { packaging {
@@ -54,6 +119,8 @@ dependencies {
implementation(libs.serialization.json) implementation(libs.serialization.json)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.androidx.core)
implementation(libs.coroutines.android)
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
implementation(libs.compose.ui) implementation(libs.compose.ui)
@@ -67,6 +134,11 @@ dependencies {
implementation(libs.navigation.compose) implementation(libs.navigation.compose)
implementation(libs.lifecycle.viewmodel.compose) implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.service)
if (hasGlyphSdk) {
implementation(files(glyphAar))
}
// Robolectric runs these on the JVM, so the UI layer is verifiable without a // 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. // device or an emulator — the same reason the logic lives in :core.
@@ -83,3 +155,14 @@ dependencies {
androidTestImplementation(libs.compose.ui.test.junit4) androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.test.manifest) debugImplementation(libs.compose.ui.test.manifest)
} }
// Make it obvious which rear display got compiled in — otherwise "the Glyph does
// nothing" is indistinguishable from "the .aar was never downloaded".
gradle.projectsEvaluated {
if (!hasGlyphSdk) {
logger.lifecycle(
"Glyph Matrix SDK not found at app/libs/ — the rear display is a no-op. " +
"Run ./fetch-glyph-sdk.sh to enable it.",
)
}
}
+27
View File
@@ -0,0 +1,27 @@
# R8 is on for release (app/build.gradle.kts). Three things need keeping.
# 1. kotlinx.serialization. The plugin generates a `Companion.serializer()` per
# @Serializable class and reaches it reflectively; without these the DTOs decode to
# nothing at runtime and every screen is empty, with no error that names the cause.
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class tech.kadence.android.data.dto.** {
*** Companion;
}
-keepclasseswithmembers class tech.kadence.android.data.dto.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class tech.kadence.android.data.dto.**$$serializer { *; }
# 2. Nothing's Glyph SDK. The matrix is driven over AIDL and the shell instantiates
# our toy service by name from the manifest, so neither end can be renamed.
-keep class com.nothing.ketchum.** { *; }
-keep class com.nothing.thirdparty.** { *; }
-keep class tech.kadence.android.glyph.KadenceGlyphToy { *; }
# 3. OkHttp ships optional hooks for platforms we do not target; R8 warns about the
# missing classes rather than the reachable code.
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
@@ -0,0 +1,162 @@
/**
* The real rear display, bound to Nothing's `GlyphMatrixManager`.
*
* Compiled only when `app/libs/glyph-matrix-sdk-2.0.aar` is present — see
* `app/build.gradle.kts` and `GlyphDisplay.kt`. The no-SDK twin lives in
* `src/noglyph/kotlin`.
*
* Three things about this SDK that the code below is shaped by:
*
* * **Binding is asynchronous.** `init(callback)` returns immediately and the matrix
* is unusable until `onServiceConnected`. `register()` must happen *in* that
* callback, not before it. Frames pushed in between are dropped, which is why the
* last one is remembered and re-sent on connect — otherwise starting an interval
* while the service is still binding leaves the matrix dark until the next tick.
*
* * **`setAppMatrixFrame`, not `setMatrixFrame`.** The developer kit is explicit:
* app-driven control must use the `App` variants, because `setMatrixFrame` fights
* with whatever Glyph Toy the user has selected. `KadenceGlyphToy` is the one place
* that legitimately calls the plain variant, because there it *is* the toy.
*
* * **Every call throws `GlyphException`.** Including after the service dies under
* you, which happens on a system update. A rear-display failure must never take
* the timer with it, so every call is caught and logged at debug.
*/
package tech.kadence.android.glyph
import android.content.ComponentName
import android.content.Context
import android.util.Log
import com.nothing.ketchum.Common
import com.nothing.ketchum.Glyph
import com.nothing.ketchum.GlyphMatrixManager
import tech.kadence.core.GlyphFrame
private const val TAG = "Kadence"
/** The package `GlyphMatrixManager.init()` binds into. Must be in the manifest's <queries>. */
private const val GLYPH_SERVICE_PACKAGE = "com.nothing.thirdparty"
internal class MatrixGlyphDisplay(context: Context) : GlyphDisplay {
private val appContext = context.applicationContext
private var manager: GlyphMatrixManager? = null
@Volatile
private var connected = false
/** Replayed on connect, so a frame pushed mid-bind is not simply lost. */
@Volatile
private var lastFrame: IntArray? = null
@Volatile
private var lastAsToy: Boolean = false
override val available: Boolean get() = connected
private val callback = object : GlyphMatrixManager.Callback {
override fun onServiceConnected(component: ComponentName?) {
val gm = manager ?: return
// The target device has to be named; the SDK rejects frames otherwise.
val target = if (Common.is25111p()) Glyph.DEVICE_25111p else Glyph.DEVICE_23112
val registered = runCatching { gm.register(target) }.getOrDefault(false)
connected = registered
Log.i(TAG, "glyph matrix connected (target=$target registered=$registered)")
if (registered) lastFrame?.let { push(it, lastAsToy) }
}
override fun onServiceDisconnected(component: ComponentName?) {
connected = false
Log.i(TAG, "glyph matrix disconnected")
}
}
override fun connect() {
if (manager != null) return
// `init()` binds to com.nothing.thirdparty/.GlyphService and *discards* the
// boolean `bindService` returns, so a bind that never happens looks exactly
// like one that is merely slow: no exception, no log, `onServiceConnected`
// simply never arrives and the matrix stays dark forever.
//
// The usual cause is package visibility — Android 11+ hides
// com.nothing.thirdparty from us unless the manifest's <queries> names it.
// Checking here turns a silent dead end into one line in logcat.
val visible = runCatching {
appContext.packageManager.getPackageInfo(GLYPH_SERVICE_PACKAGE, 0) != null
}.getOrDefault(false)
if (!visible) {
Log.w(
TAG,
"glyph: $GLYPH_SERVICE_PACKAGE is not visible to us. Either this is " +
"not a Nothing phone, or the manifest is missing its " +
"<queries><package android:name=\"$GLYPH_SERVICE_PACKAGE\"/></queries>.",
)
return
}
val gm = runCatching { GlyphMatrixManager.getInstance(appContext) }.getOrNull()
if (gm == null) {
Log.w(TAG, "glyph matrix unavailable on this device")
return
}
manager = gm
runCatching { gm.init(callback) }
.onSuccess { Log.i(TAG, "glyph: bind requested, waiting for the service") }
.onFailure { Log.w(TAG, "glyph init failed: ${it.message}") }
}
override fun show(dots: IntArray, asToy: Boolean) {
lastFrame = dots
lastAsToy = asToy
if (!connected) return
push(dots, asToy)
}
private fun push(dots: IntArray, asToy: Boolean) {
val gm = manager ?: return
try {
// The developer kit's rule: the App variant for app-driven control, the
// plain one only when we are the selected toy. Using setMatrixFrame for
// app control visibly fights whatever toy the user has chosen.
if (asToy) gm.setMatrixFrame(dots) else gm.setAppMatrixFrame(dots)
} catch (e: Throwable) {
// Includes GlyphException. The rear display is a nicety; the interval is not.
Log.d(TAG, "glyph frame rejected: ${e.message}")
}
}
override fun clear() {
val wasToy = lastAsToy
lastFrame = null
val gm = manager ?: return
try {
// A toy blanks by drawing nothing; closeAppMatrix is for app control and
// would hand the matrix back to the shell mid-toy.
if (wasToy) gm.setMatrixFrame(IntArray(glyphMatrixSize() * glyphMatrixSize()))
else gm.closeAppMatrix()
} catch (e: Throwable) {
Log.d(TAG, "glyph clear failed: ${e.message}")
}
}
override fun release() {
val gm = manager ?: return
clear()
runCatching { gm.unInit() }
manager = null
connected = false
}
}
internal fun newGlyphDisplay(context: Context): GlyphDisplay = MatrixGlyphDisplay(context)
internal fun glyphMatrixSize(): Int =
runCatching { Common.getDeviceMatrixLength() }
.getOrNull()
?.takeIf { it > 0 }
// The SDK reports the *total* dot count; the renderer works in a square.
?.let { total ->
val side = Math.round(Math.sqrt(total.toDouble())).toInt()
if (side * side == total) side else GlyphFrame.DEFAULT_SIZE
}
?: GlyphFrame.DEFAULT_SIZE
+82 -1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- <!--
Kadence never declares VpnService. Android allows exactly one active VPN, and on Kadence never declares VpnService. Android allows exactly one active VPN, and on
@@ -8,11 +9,42 @@
usage stats. Neither takes the VPN slot. Please keep it that way. usage stats. Neither takes the VPN slot. Please keep it that way.
--> -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- The countdown notification, and the alarm when an interval ends. Runtime
permission since Android 13; the timer screen asks for it. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- What keeps the process alive while the app is off screen. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- The rear Glyph Matrix. Harmless on a phone that has none: the permission is
simply never granted by any system component and the SDK reports unavailable. -->
<uses-permission android:name="com.nothing.ketchum.permission.ENABLE" />
<!--
Package visibility. `GlyphMatrixManager.init()` binds to
com.nothing.thirdparty/.GlyphService, and since Android 11 a package not named
here is simply invisible: `bindService` returns false and nothing else happens.
The SDK discards that return value, so without this the failure is completely
silent — no exception, no log, `onServiceConnected` never fires, and the matrix
stays dark while every other part of the app works. That is exactly the symptom
this declaration fixes, so please do not "tidy it away" as unused.
-->
<queries>
<package android:name="com.nothing.thirdparty" />
</queries>
<application <application
android:name=".KadenceApplication" android:name=".KadenceApplication"
android:allowBackup="false" android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Kadence"> android:theme="@style/Theme.Kadence">
@@ -20,11 +52,60 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:launchMode="singleTop"
android:theme="@style/Theme.Kadence"> android:theme="@style/Theme.Kadence">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!--
`specialUse` is the honest type. A pomodoro is not mediaPlayback, not
location, not dataSync, and Android 14+ requires the declared type to be
accurate. The subtype property below is what that declaration has to
explain; Play would read it during review.
-->
<service
android:name=".service.TimerService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="A user-started focus interval whose countdown and
end-of-interval alarm must stay accurate while the app is off
screen. The timing is owned by the user's own server; this service
renders it and posts the Live Update." />
</service>
<!--
The Glyph Toy. Nothing's shell binds this when the user cycles the Glyph
button round to Kadence, so it must be exported and carry the toy metadata
or it will not appear in Settings → Glyph Interface → Glyph Toys.
`longpress` is on because a long press toggles the interval, matching what
the stock timer toy's long press does.
-->
<service
android:name=".glyph.KadenceGlyphToy"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="com.nothing.glyph.TOY" />
</intent-filter>
<meta-data
android:name="com.nothing.glyph.toy.name"
android:resource="@string/toy_name" />
<meta-data
android:name="com.nothing.glyph.toy.image"
android:resource="@drawable/ic_glyph_toy_preview" />
<meta-data
android:name="com.nothing.glyph.toy.summary"
android:resource="@string/toy_summary" />
<meta-data
android:name="com.nothing.glyph.toy.longpress"
android:value="1" />
</service>
</application> </application>
</manifest> </manifest>
@@ -1,5 +1,114 @@
/**
* The object graph, such as it is.
*
* Hand-wired rather than injected: there are six singletons and one of them is an
* HTTP client. A DI framework here would be more code than it replaces, and the one
* thing that genuinely must be a singleton — [TimerEngine] — is clearer for being
* visibly constructed once, in one place.
*
* Why the engine lives here and not in a ViewModel: the notification, the rear matrix
* and the Compose UI must all read the *same* countdown. A ViewModel dies with its
* Activity, so the service would need its own — and two engines polling the same
* backend drift apart within seconds, which the user sees as a notification that
* disagrees with the dial. Application scope is the narrowest scope that is correct.
*
* The Application also owns the decision to *run* the foreground service: it watches
* the engine and starts the service whenever an interval is active. Doing it here
* rather than from the UI means it also happens when the interval was started
* somewhere else entirely — the desktop app, the web UI, a Telegram command.
*/
package tech.kadence.android package tech.kadence.android
import android.app.Application import android.app.Application
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.net.KadenceApi
import tech.kadence.android.data.store.KadencePrefs
import tech.kadence.android.glyph.GlyphDisplay
import tech.kadence.android.glyph.newGlyphDisplay
import tech.kadence.android.notify.Notifications
import tech.kadence.android.service.TimerService
import tech.kadence.android.timer.TimerEngine
class KadenceApplication : Application() private const val TAG = "Kadence"
class KadenceApplication : Application() {
/** Outlives every Activity, by design — see the file header. */
private val scope = CoroutineScope(SupervisorJob())
lateinit var prefs: KadencePrefs
private set
lateinit var api: KadenceApi
private set
lateinit var repo: KadenceRepository
private set
lateinit var engine: TimerEngine
private set
/**
* One bound matrix for app-driven frames.
*
* Built eagerly but connected lazily by whoever uses it, because binding Nothing's
* service at process start would be a cost paid on every cold launch for a feature
* that only matters while an interval runs. [KadenceGlyphToy] deliberately does
* *not* share this one — see its header.
*/
val glyphDisplay: GlyphDisplay by lazy { newGlyphDisplay(this) }
override fun onCreate() {
super.onCreate()
Notifications.ensureChannels(this)
prefs = KadencePrefs(this)
// `config()` is read per request rather than captured, so changing the backend
// in Settings takes effect on the next call instead of needing a restart.
api = KadenceApi(config = { prefs.current })
repo = KadenceRepository(api)
engine = TimerEngine(
repo = repo,
scope = scope,
configured = { prefs.current.isConfigured && prefs.current.onboarded },
)
// A 401 on anything but the login path means the session died mid-use. The
// engine will report Unauthorized on its next poll; this just makes it prompt.
api.unauthorized.subscribe {
Log.i(TAG, "backend rejected our credentials")
engine.refreshNow()
}
engine.start()
// Start or stop the foreground service purely as a function of whether an
// interval is running. `distinctUntilChanged` matters: without it every tick
// would fire another startForegroundService.
scope.launch {
engine.state
.map { it.isActive }
.distinctUntilChanged()
.collect { active ->
if (active) {
TimerService.ensureRunning(this@KadenceApplication)
}
// Not stopped from here: the service drops out of the foreground
// itself when it sees an inactive state, which it can do in the
// same pass as removing its notification.
}
}
// Re-point the HTTP client's cookie jar when the backend URL changes, so a
// session for the old host is not offered to the new one.
scope.launch {
prefs.config
.map { it.baseUrl }
.distinctUntilChanged()
.collect { api.cookieJar.clear() }
}
}
}
@@ -1,176 +1,47 @@
/**
* The one Activity.
*
* Thin by design: it owns the theme and a resume-time poll, and nothing else. The
* timer lives on the Application (see [KadenceApplication]) precisely so that rotating
* the phone or backgrounding the app cannot interrupt a countdown.
*/
package tech.kadence.android package tech.kadence.android
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement import androidx.lifecycle.Lifecycle
import androidx.compose.foundation.layout.Column import androidx.lifecycle.lifecycleScope
import androidx.compose.foundation.layout.Row import androidx.lifecycle.repeatOnLifecycle
import androidx.compose.foundation.layout.Spacer import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.getValue
import androidx.compose.runtime.mutableIntStateOf import kotlinx.coroutines.launch
import androidx.compose.runtime.mutableStateOf import tech.kadence.android.ui.KadenceApp
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.android.ui.theme.KadenceTheme
import tech.kadence.core.Kind
import tech.kadence.core.KIND_LABEL
import tech.kadence.core.humanMinutes
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val app: KadenceApplication get() = application as KadenceApplication
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
// Coming back to the app is the moment the state is most likely to be stale —
// the interval may have been paused on the desktop, or finished while the
// phone dozed. Poll on every resume rather than trusting the last tick.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
app.engine.refreshNow()
}
}
setContent { setContent {
KadenceTheme { val config by app.prefs.config.collectAsStateWithLifecycle()
Surface( KadenceTheme(dynamicAccent = config.dynamicAccent) {
modifier = Modifier.fillMaxSize(), KadenceApp(app)
color = KadenceTheme.colors.paper,
) {
DesignGallery()
}
} }
} }
} }
} }
/**
* A scratch screen for the design system, standing in until the real Timer screen
* lands. It exists so the tokens, the type and the dial can be checked against the web
* app side by side on a device.
*/
@Composable
private fun DesignGallery() {
val colors = KadenceTheme.colors
val planned = 25 * 60
// A live countdown, so the dial and the fixed-advance clock can be watched ticking.
var remaining by remember { mutableIntStateOf(18 * 60 + 24) }
var paused by remember { mutableStateOf(false) }
var preset by remember { mutableIntStateOf(0) }
LaunchedEffect(paused) {
while (!paused) {
delay(1000)
remaining = if (remaining > 0) remaining - 1 else planned
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(22.dp),
) {
PageHeader(
eyebrow = "Monday, 19 October",
title = KIND_LABEL.getValue(Kind.WORK),
caption = "Tracking \"Fix the flaky importer\"",
) {
GhostButton(text = if (paused) "Resume" else "Pause", onClick = { paused = !paused })
PrimaryButton(text = "Finish", onClick = { remaining = planned })
}
Panel(padding = 24.dp) {
TimerDial(
label = KIND_LABEL.getValue(Kind.WORK),
remaining = { remaining },
plannedSeconds = planned,
accent = KadenceTheme.viz.work,
sublabel = "Cycle 2 of 4 before a long break",
paused = paused,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(14.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
listOf("Classic", "Пара", "Custom").forEachIndexed { index, name ->
PresetChip(
text = name,
selected = preset == index,
onClick = { preset = index },
)
}
}
}
SectionTitle("Today", trailing = "6 of 8")
Panel(padding = 0.dp) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Stat(
label = "Focus today",
value = humanMinutes(155),
caption = "35m resting",
modifier = Modifier.weight(1f).padding(16.dp),
)
VerticalRule(height = 70.dp)
Stat(
label = "Interruptions",
value = "3",
caption = "counted, not stopped",
tabular = true,
modifier = Modifier.weight(1f).padding(16.dp),
)
}
HorizontalRule()
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Stat(
label = "Score",
value = "72",
caption = "out of 100",
size = StatSize.Large,
accent = colors.accent,
tabular = true,
modifier = Modifier.weight(1f).padding(16.dp),
)
VerticalRule(height = 84.dp)
Stat(
label = "Streak",
value = "4d",
caption = "best 11",
size = StatSize.Large,
modifier = Modifier.weight(1f).padding(16.dp),
)
}
}
SectionTitle("Palette")
Panel {
Text(
text = "The accent is taken from the system palette; paper, ink and the " +
"hairlines stay Kadence's. Chart colours never follow the accent.",
style = KadenceTheme.type.bodySmall,
color = colors.inkMuted,
)
}
}
}
@@ -0,0 +1,182 @@
/**
* Every endpoint this client uses, named once.
*
* A thin typed layer over [KadenceApi] — no caching, no state. State lives in
* `TimerEngine`; this file's only job is that a path or a request body is spelled out
* in exactly one place. Paths are checked against the routers in `backend/app/api`:
*
* * `routes_timer.py` → `/api/timer/state`, `start`, `pause`, `complete`, …
* * `routes_tasks.py` → `/api/tasks`, `/api/tasks/refresh`
* * `routes_reports.py` → `/api/reports/daily`, `/api/stats/summary`
* * `routes_blocker.py` → `/api/blocker/policy`, `/api/blocker/events`
* * `routes_auth.py` → `/api/auth/me`, `login`; mounted outside `require_auth`
*
* Note what is *not* here: there is no `/api/timer/stop`. Stopping is `abandon`, which
* under strict mode needs a reason — see `abandonNeedsReason` in `:core`.
*/
package tech.kadence.android.data
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonObject
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.HealthDto
import tech.kadence.android.data.dto.PomodoroPresetDto
import tech.kadence.android.data.dto.RefreshResultDto
import tech.kadence.android.data.dto.SettingsEnvelopeDto
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.ApiResult
import tech.kadence.android.data.net.KadenceApi
import tech.kadence.android.data.net.KadenceJson
class KadenceRepository(val api: KadenceApi) {
// ---- meta ---------------------------------------------------------------
suspend fun health(): ApiResult<HealthDto> = api.get("/api/health")
suspend fun authMe(): ApiResult<AuthMeDto> = api.get("/api/auth/me")
// ---- timer --------------------------------------------------------------
suspend fun timerState(): ApiResult<TimerStateDto> = api.get("/api/timer/state")
/**
* @param minutes null means "whatever the server's settings say for this kind",
* which is the normal case — the phone does not hold a copy of the durations.
*/
suspend fun start(
kind: String? = null,
minutes: Int? = null,
taskId: Long? = null,
note: String? = null,
): ApiResult<TimerStateDto> = api.post(
"/api/timer/start",
body(
"kind" to kind,
"minutes" to minutes,
"task_id" to taskId,
"note" to note,
),
)
suspend fun pause(): ApiResult<TimerStateDto> = api.post("/api/timer/pause")
suspend fun resume(): ApiResult<TimerStateDto> = api.post("/api/timer/resume")
suspend fun complete(autoNext: Boolean = true): ApiResult<TimerStateDto> =
api.post("/api/timer/complete", body("auto_next" to autoNext))
suspend fun abandon(reason: String? = null): ApiResult<TimerStateDto> =
api.post("/api/timer/abandon", body("reason" to reason))
suspend fun interrupt(): ApiResult<TimerStateDto> = api.post("/api/timer/interrupt")
suspend fun extend(minutes: Int = 5): ApiResult<TimerStateDto> =
api.post("/api/timer/extend", body("minutes" to minutes))
suspend fun attachTask(taskId: Long?): ApiResult<TimerStateDto> =
api.post("/api/timer/task", body("task_id" to taskId))
// ---- tasks --------------------------------------------------------------
suspend fun tasks(): ApiResult<TaskListDto> = api.get("/api/tasks")
/** Fans out to Jira, Todoist, Google and the laundry site; slow by nature. */
suspend fun refreshTasks(): ApiResult<RefreshResultDto> = api.post("/api/tasks/refresh")
// ---- reports ------------------------------------------------------------
suspend fun dailyReport(): ApiResult<DailyReportEnvelopeDto> = api.get("/api/reports/daily")
suspend fun statsSummary(): ApiResult<StatsSummaryDto> = api.get("/api/stats/summary")
// ---- settings -----------------------------------------------------------
suspend fun settings(): ApiResult<SettingsEnvelopeDto> = api.get("/api/settings")
/**
* Patch the pomodoro block of the settings document.
*
* A *partial* PUT on purpose: `SettingsPatch` is `extra="allow"` and the save
* deep-merges, so naming only what changed leaves the rest of the document — the
* block lists, the Telegram tokens, the report schedule — untouched. Sending a
* whole document reconstructed from this client's narrow DTO would silently
* clobber every field the phone does not model.
*/
suspend fun patchPomodoro(fields: Map<String, Any>): ApiResult<SettingsEnvelopeDto> {
val inner = JsonObject(fields.mapValues { (_, v) -> jsonPrimitiveOf(v) })
val payload = JsonObject(mapOf("pomodoro" to inner))
return api.put("/api/settings", KadenceJson.encodeToString(payload))
}
suspend fun setTimerEnabled(enabled: Boolean): ApiResult<SettingsEnvelopeDto> =
patchPomodoro(mapOf("timer_enabled" to enabled))
/** Apply one of the server's named rhythms. */
suspend fun applyPreset(preset: PomodoroPresetDto): ApiResult<SettingsEnvelopeDto> =
patchPomodoro(
mapOf(
"work_minutes" to preset.workMinutes,
"short_break_minutes" to preset.shortBreakMinutes,
"long_break_minutes" to preset.longBreakMinutes,
"cycles_before_long_break" to preset.cyclesBeforeLongBreak,
),
)
// ---- blocker ------------------------------------------------------------
suspend fun blockerPolicy(): ApiResult<BlockerPolicyDto> = api.get("/api/blocker/policy")
suspend fun blockEvents(): ApiResult<BlockEventsDto> = api.get("/api/blocker/events")
/** Report an attempt the phone turned away, so it lands in the evening report. */
suspend fun recordBlockEvent(
kind: String,
target: String,
action: String = "blocked",
details: String? = null,
): ApiResult<JsonObject> = api.post(
"/api/blocker/events",
body(
"kind" to kind,
"target" to target,
"action" to action,
"details" to details,
),
)
}
/**
* Build a JSON object from the pairs whose value is not null.
*
* Dropping nulls is not cosmetic. The settings PUT deep-merges, so an explicit null
* would clobber a stored value; and `StartTimerRequest.minutes` is `int | None` with
* `ge=1`, so sending `null` is fine but sending it *implicitly* by omission is what
* actually means "use the configured length".
*/
private fun jsonPrimitiveOf(value: Any): kotlinx.serialization.json.JsonPrimitive = when (value) {
is String -> kotlinx.serialization.json.JsonPrimitive(value)
is Int -> kotlinx.serialization.json.JsonPrimitive(value)
is Long -> kotlinx.serialization.json.JsonPrimitive(value)
is Boolean -> kotlinx.serialization.json.JsonPrimitive(value)
else -> kotlinx.serialization.json.JsonPrimitive(value.toString())
}
private fun body(vararg pairs: Pair<String, Any?>): String {
val fields = pairs.mapNotNull { (key, value) ->
when (value) {
null -> null
is String -> key to kotlinx.serialization.json.JsonPrimitive(value)
is Int -> key to kotlinx.serialization.json.JsonPrimitive(value)
is Long -> key to kotlinx.serialization.json.JsonPrimitive(value)
is Boolean -> key to kotlinx.serialization.json.JsonPrimitive(value)
else -> key to kotlinx.serialization.json.JsonPrimitive(value.toString())
}
}.toMap()
return KadenceJson.encodeToString(JsonObject(fields))
}
@@ -308,3 +308,46 @@ data class SessionsResponseDto(
val sessions: List<DaySessionDto> = emptyList(), val sessions: List<DaySessionDto> = emptyList(),
val metrics: DayMetricsDto = DayMetricsDto(), val metrics: DayMetricsDto = DayMetricsDto(),
) )
// ---- settings --------------------------------------------------------------
// Only the fields this client reads or writes. The settings document is large and
// mostly the server's business; declaring all of it here would mean a phone release
// every time the backend grows a field. `SettingsPatch` on the backend is
// `extra="allow"` and the save deep-merges, so a partial PUT keeps everything we
// have not named — which is what makes this narrow view safe.
@Serializable
data class PomodoroPresetDto(
val name: String = "",
@SerialName("work_minutes") val workMinutes: Int = 25,
@SerialName("short_break_minutes") val shortBreakMinutes: Int = 5,
@SerialName("long_break_minutes") val longBreakMinutes: Int = 15,
@SerialName("cycles_before_long_break") val cyclesBeforeLongBreak: Int = 4,
)
@Serializable
data class PomodoroSettingsDto(
@SerialName("work_minutes") val workMinutes: Int = 25,
@SerialName("short_break_minutes") val shortBreakMinutes: Int = 5,
@SerialName("long_break_minutes") val longBreakMinutes: Int = 15,
@SerialName("cycles_before_long_break") val cyclesBeforeLongBreak: Int = 4,
@SerialName("daily_goal_pomodoros") val dailyGoalPomodoros: Int = 8,
@SerialName("timer_enabled") val timerEnabled: Boolean = true,
@SerialName("strict_mode") val strictMode: Boolean = false,
@SerialName("allow_pause") val allowPause: Boolean = true,
@SerialName("auto_start_breaks") val autoStartBreaks: Boolean = false,
@SerialName("auto_start_work") val autoStartWork: Boolean = false,
val presets: List<PomodoroPresetDto> = emptyList(),
)
@Serializable
data class AppSettingsDto(
val pomodoro: PomodoroSettingsDto = PomodoroSettingsDto(),
val timezone: String = "",
)
/** `GET /api/settings` answers `{defaults, settings}`, not a bare document. */
@Serializable
data class SettingsEnvelopeDto(
val settings: AppSettingsDto = AppSettingsDto(),
)
@@ -140,6 +140,15 @@ class KadenceApi(
parseErrorDetail(text, "${response.code} ${response.message}"), parseErrorDetail(text, "${response.code} ${response.message}"),
) )
} }
// Debug builds only. "The screen is empty and nobody knows why" is
// almost always answered by the body, but a response body is not
// something to write to the log of a shipped app.
if (tech.kadence.android.BuildConfig.DEBUG) {
android.util.Log.d(
"Kadence",
"HTTP $method $path -> ${response.code} ${text?.take(400)}",
)
}
ApiResult.Ok(text.orEmpty()) ApiResult.Ok(text.orEmpty())
} }
} catch (e: IOException) { } catch (e: IOException) {
@@ -0,0 +1,105 @@
/**
* Where the backend lives, and the handful of local-only preferences.
*
* `SharedPreferences` rather than DataStore, for one specific reason: `TimerService`
* can be started by the system (a boot completion, a restart after being killed) and
* needs the base URL and token *in `onCreate`*, before any coroutine has run. DataStore
* is suspend-only, so the service would have to post a notification saying nothing
* while it waited for its own configuration. A synchronous first read is worth more
* here than DataStore's other guarantees — and at this size (five keys, written only
* from the settings screen) there is nothing else to gain.
*
* Everything the *server* owns — interval lengths, the goal, the block lists, the
* report schedule — is deliberately absent. Those live in the settings document and
* are edited in the web UI; duplicating them here is how two clients start disagreeing.
*/
package tech.kadence.android.data.store
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import tech.kadence.android.BuildConfig
import tech.kadence.android.data.net.AuthConfig
/** The local configuration, as one immutable snapshot. */
data class KadenceConfig(
override val baseUrl: String = BuildConfig.DEFAULT_BACKEND,
override val apiToken: String? = null,
/** Draw the timer on the rear matrix while an interval runs. */
val glyphEnabled: Boolean = true,
/** Take the single accent from the system's wallpaper palette. */
val dynamicAccent: Boolean = true,
/** Ring and vibrate when an interval ends. */
val alarmOnComplete: Boolean = true,
/** Set once the user has got past onboarding with a reachable backend. */
val onboarded: Boolean = false,
) : AuthConfig {
/** Enough to attempt a call: a URL, and a token if the server wants one. */
val isConfigured: Boolean get() = baseUrl.isNotBlank()
}
class KadencePrefs(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("kadence", Context.MODE_PRIVATE)
private val _config = MutableStateFlow(read())
val config: StateFlow<KadenceConfig> = _config.asStateFlow()
/** The current value, readable from any thread without suspending. */
val current: KadenceConfig get() = _config.value
private fun read() = KadenceConfig(
baseUrl = prefs.getString(KEY_BASE_URL, null)?.takeIf { it.isNotBlank() }
?: BuildConfig.DEFAULT_BACKEND,
apiToken = prefs.getString(KEY_API_TOKEN, null)?.takeIf { it.isNotBlank() },
glyphEnabled = prefs.getBoolean(KEY_GLYPH, true),
dynamicAccent = prefs.getBoolean(KEY_DYNAMIC_ACCENT, true),
alarmOnComplete = prefs.getBoolean(KEY_ALARM, true),
onboarded = prefs.getBoolean(KEY_ONBOARDED, false),
)
fun update(transform: (KadenceConfig) -> KadenceConfig) {
val next = transform(_config.value)
prefs.edit().apply {
putString(KEY_BASE_URL, next.baseUrl)
putString(KEY_API_TOKEN, next.apiToken.orEmpty())
putBoolean(KEY_GLYPH, next.glyphEnabled)
putBoolean(KEY_DYNAMIC_ACCENT, next.dynamicAccent)
putBoolean(KEY_ALARM, next.alarmOnComplete)
putBoolean(KEY_ONBOARDED, next.onboarded)
}.apply()
_config.value = next
}
/**
* Normalise what someone typed into a URL okhttp will accept.
*
* A phone keyboard offers no scheme and people type a trailing slash, so
* `kadence.timka.su` and `kadence.timka.su/` both have to become a usable base.
* HTTPS is the default guess; a bare LAN IP or `.local` name almost certainly
* is not, so those get HTTP rather than a confusing TLS failure.
*/
companion object {
private const val KEY_BASE_URL = "base_url"
private const val KEY_API_TOKEN = "api_token"
private const val KEY_GLYPH = "glyph_enabled"
private const val KEY_DYNAMIC_ACCENT = "dynamic_accent"
private const val KEY_ALARM = "alarm_on_complete"
private const val KEY_ONBOARDED = "onboarded"
fun normalizeBaseUrl(raw: String): String {
val trimmed = raw.trim().trimEnd('/')
if (trimmed.isEmpty()) return ""
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed
val host = trimmed.substringBefore('/').substringBefore(':')
val looksLocal = host == "localhost" ||
host.endsWith(".local") ||
host.toIntOrNull() != null ||
Regex("""^\d{1,3}(\.\d{1,3}){3}$""").matches(host)
return if (looksLocal) "http://$trimmed" else "https://$trimmed"
}
}
}
@@ -0,0 +1,80 @@
/**
* The rear Glyph Matrix, as the rest of the app is allowed to see it.
*
* Narrow on purpose: push a flat array of dot brightness, or turn it off. Every
* decision about *what* to draw — the digits, the draining ring, the dot grid — is
* `GlyphRenderer` in `:core`, which is pure arithmetic and tested as ASCII art on a
* laptop. Nothing in this package computes a pixel.
*
* Two implementations exist and exactly one is compiled, chosen in
* `app/build.gradle.kts` by whether `libs/glyph-matrix-sdk-2.0.aar` is present:
*
* * `src/glyph/kotlin` — binds Nothing's `GlyphMatrixManager`.
* * `src/noglyph/kotlin` — accepts frames and drops them.
*
* The .aar is not redistributable (its licence, clause 2.1(c)), so it cannot be
* committed; `./fetch-glyph-sdk.sh` downloads it. Without it the app builds, installs
* and runs with a dead rear display and no other difference — which is also what
* happens on any phone that is not a Nothing.
*
* `newGlyphDisplay(context)` is declared by whichever source set is compiled.
*/
package tech.kadence.android.glyph
import android.content.Context
interface GlyphDisplay {
/**
* True once the matrix is bound and will actually light up.
*
* Binding is asynchronous, so this is false for a moment after [connect] even on
* a Phone (3). Callers push frames regardless and let them be dropped; gating on
* this would mean the first second of an interval never draws.
*/
val available: Boolean
/** Bind the service and register this app against the device. Idempotent. */
fun connect()
/**
* Push one frame. `dots.size` must be the device's matrix length.
*
* @param asToy send it as the *selected Glyph Toy* rather than as app-driven
* control. The SDK has two separate calls for this and the developer kit is
* explicit that app control must use the `App` variant, because the plain one
* fights with whatever toy the user has chosen. Only [KadenceGlyphToy] passes
* true, and only because there it genuinely is the selected toy.
*/
fun show(dots: IntArray, asToy: Boolean = false)
/** Blank the matrix but stay bound. */
fun clear()
/** Unbind. After this, [connect] is needed again. */
fun release()
}
/** The display for a device with no Glyph Matrix, or a build without the SDK. */
internal class NoopGlyphDisplay : GlyphDisplay {
override val available: Boolean get() = false
override fun connect() = Unit
override fun show(dots: IntArray, asToy: Boolean) = Unit
override fun clear() = Unit
override fun release() = Unit
}
/**
* The two functions each variant source set must provide.
*
* Not `expect`/`actual` — that needs a multiplatform module, and this is one Android
* target with two mutually exclusive source directories. A plain top-level function
* declared in both places gives the same compile-time choice with none of the setup;
* only one of the two files is ever on the source path.
*
* `newGlyphDisplay(context): GlyphDisplay`
* `glyphMatrixSize(): Int` — the device's matrix length. Phone (3) is 25x25, but
* Phone (4a) Pro is not, so the real build asks the
* SDK rather than trusting `GlyphFrame`'s default.
*/
@Suppress("unused")
private const val VARIANT_CONTRACT_NOTE = "see src/glyph and src/noglyph"
@@ -0,0 +1,87 @@
/**
* Turns the timer's state into rear-matrix frames.
*
* The only thing between [TimerUi] and [GlyphDisplay], and it holds no policy of its
* own: `GlyphRenderer` in `:core` decides what a frame looks like, this decides *when*
* to send one.
*
* Which matters more than it sounds. The matrix is driven over a bound service and
* every frame is an IPC round trip, so re-sending an identical 625-int array at 1 Hz
* while the timer is paused is pure waste. Frames are therefore pushed only when the
* picture would actually change — which for a running countdown is once a second, and
* for a paused or idle one is not at all.
*/
package tech.kadence.android.glyph
import tech.kadence.android.timer.TimerUi
import tech.kadence.core.GlyphRenderer
import tech.kadence.core.asciiOf
class GlyphPresenter(private val display: GlyphDisplay) {
private val size = glyphMatrixSize()
/** What was last sent, so an unchanged picture is not sent again. */
private var lastKey: String? = null
fun connect() = display.connect()
fun release() {
lastKey = null
display.release()
}
/**
* @param enabled the user's Settings toggle. When it goes false the matrix is
* blanked once rather than simply left showing the last frame forever.
*/
fun render(ui: TimerUi, enabled: Boolean) {
if (!enabled) {
if (lastKey != null) {
display.clear()
lastKey = null
}
return
}
val frame = when {
ui.isActive -> GlyphRenderer.timerFrame(
remainingSeconds = ui.remaining,
plannedSeconds = ui.plannedSeconds,
paused = ui.isPaused,
size = size,
)
// Idle: today's pomodoros against the goal, the same dot encoding the web
// app uses. Something true on the back of the phone beats a blank panel.
else -> GlyphRenderer.dotsFrame(
done = ui.completedToday,
goal = ui.goal,
size = size,
)
}
// The frame itself is the identity: two states that draw the same picture are
// the same picture. Cheaper and more honest than comparing the fields that
// happen to feed it.
val key = frameKey(ui, frame.litCount)
if (key == lastKey) return
lastKey = key
display.show(frame.render())
}
private fun frameKey(ui: TimerUi, litCount: Int): String =
if (ui.isActive) {
"t:${ui.remaining}:${ui.plannedSeconds}:${ui.isPaused}"
} else {
"d:${ui.completedToday}:${ui.goal}:$litCount"
}
/** For `adb logcat` when the matrix is dark and nobody knows why. */
fun debugAscii(ui: TimerUi): String {
val frame = if (ui.isActive) {
GlyphRenderer.timerFrame(ui.remaining, ui.plannedSeconds, ui.isPaused, size)
} else {
GlyphRenderer.dotsFrame(ui.completedToday, ui.goal, size)
}
return asciiOf(frame)
}
}
@@ -0,0 +1,137 @@
/**
* Kadence as a Glyph Toy — the thing the Glyph button cycles to.
*
* Distinct from what `TimerService` does with the matrix, and both are wanted:
*
* * `TimerService` pushes through `setAppMatrixFrame` while an interval runs. That is
* app-driven: the timer appears on the back of the phone because a pomodoro is
* running, whatever toy is selected.
* * This is the *toy*. Nothing's shell binds it when the user presses the Glyph button
* round to Kadence, and it draws through `setMatrixFrame` — the plain variant, which
* is correct here precisely because here we *are* the selected toy.
*
* The lifecycle is Nothing's, not ours: `onBind` means "selected, start drawing" and
* `onUnbind` means "deselected, stop". Returning the `Messenger`'s binder is what makes
* the Glyph button's long press arrive as a `change` event; return null and the toy
* still draws but the button does nothing. A long press here toggles the interval, as
* the stock timer toy's long press does, so the gesture means the same thing it means
* elsewhere on the phone.
*
* **This file deliberately does not import the Glyph SDK.** It lives in the main source
* set, so it is compiled with or without the .aar, and an import would break the build
* that has no .aar — the one a fresh clone gets. The four values it needs from
* `com.nothing.ketchum.GlyphToy` are compile-time constants (`MSG_GLYPH_TOY = 1`,
* `"data"`, `"change"`, `"aod"`), so inlining them costs nothing and keeps every SDK
* reference inside `src/glyph/kotlin`, where it belongs. The display itself arrives via
* `newGlyphDisplay`, which is already the variant-selected one — so without the SDK
* this service binds, draws into a no-op, and harms nothing.
*
* Registered in the manifest with `com.nothing.glyph.TOY` plus the name and preview
* metadata the Glyph Toys settings list requires.
*/
package tech.kadence.android.glyph
import android.app.Service
import android.content.Intent
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import tech.kadence.android.KadenceApplication
private const val TAG = "Kadence"
// Mirrored from com.nothing.ketchum.GlyphToy — see the note in the file header.
private const val MSG_GLYPH_TOY = 1
private const val MSG_GLYPH_TOY_DATA = "data"
private const val EVENT_CHANGE = "change"
private const val EVENT_AOD = "aod"
class KadenceGlyphToy : Service() {
private val app: KadenceApplication get() = application as KadenceApplication
private val scope = CoroutineScope(SupervisorJob())
private var presenter: GlyphPresenter? = null
private var job: Job? = null
/**
* Its own display, not the one `TimerService` holds.
*
* They draw through different SDK calls (`setMatrixFrame` vs `setAppMatrixFrame`)
* and have independent lifetimes, so sharing one bound manager would mean one of
* them unbinding the other's matrix on deselection.
*/
override fun onBind(intent: Intent?): IBinder {
Log.i(TAG, "glyph toy selected")
val own = ToyDisplay(newGlyphDisplay(this))
val toyPresenter = GlyphPresenter(own).also { it.connect() }
presenter = toyPresenter
job = scope.launch {
// The same engine the UI and the notification read, so the toy shows the
// real interval — including one started on the desktop or in Telegram.
app.engine.state.collectLatest { ui -> toyPresenter.render(ui, enabled = true) }
}
return messenger.binder
}
override fun onUnbind(intent: Intent?): Boolean {
Log.i(TAG, "glyph toy deselected")
job?.cancel()
job = null
presenter?.release()
presenter = null
return false
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private val handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
if (msg.what != MSG_GLYPH_TOY) {
super.handleMessage(msg)
return
}
when (msg.data?.getString(MSG_GLYPH_TOY_DATA)) {
// Long press. The stock timer toy starts/stops on this, so Kadence
// does the same: start the next interval, or pause/resume a running one.
EVENT_CHANGE -> {
Log.i(TAG, "glyph button long press — toggling the interval")
app.engine.toggle()
}
// Sent once a minute while Kadence is the always-on toy. A poll here
// keeps the matrix honest without the app being open at all.
EVENT_AOD -> app.engine.refreshNow()
}
}
}
private val messenger = Messenger(handler)
}
/**
* Marks frames as belonging to the selected toy rather than to app-driven control.
*
* A wrapper rather than a flag on the real display, so "app control always uses the
* `App` variants" stays true of that class without exception.
*/
private class ToyDisplay(private val inner: GlyphDisplay) : GlyphDisplay {
override val available: Boolean get() = inner.available
override fun connect() = inner.connect()
// The parameter is ignored on purpose: everything drawn through this wrapper is
// the selected toy's, whatever the caller thought.
override fun show(dots: IntArray, asToy: Boolean) = inner.show(dots, asToy = true)
override fun clear() = inner.clear()
override fun release() = inner.release()
}
@@ -0,0 +1,287 @@
/**
* The notifications, including the Android 16 Live Update.
*
* Two channels, because they want opposite things. The running interval must be silent
* and unswipeable; the interval *ending* must make a noise and be dismissible. One
* channel cannot be both, and a user who silences the alarm to stop the countdown
* buzzing has silenced the only part that mattered.
*
* **The Live Update.** On Android 16 an ongoing notification can be *promoted*: it
* gets a chip in the status bar and a rich row on the lock screen, which is exactly
* what a pomodoro countdown wants. The promotion is not granted for asking — the
* platform requires all of:
*
* * `setOngoing(true)`,
* * `setRequestPromotedOngoing(true)`,
* * and a style the system knows how to draw small, which for a countdown means
* [NotificationCompat.ProgressStyle].
*
* Miss any one and it silently renders as an ordinary notification — no error, no log.
* `setShortCriticalText` is what the status-bar chip actually shows, so it gets the
* minutes remaining and nothing else; there is room for about six characters.
*
* This goes through `NotificationCompat` rather than the platform builder so there are
* no `SDK_INT` branches here: on an older release the same code posts a plain ongoing
* notification with a progress bar, which is the correct fallback.
*/
package tech.kadence.android.notify
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.RingtoneManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import tech.kadence.android.MainActivity
import tech.kadence.android.R
import tech.kadence.android.service.TimerService
import tech.kadence.android.timer.TimerUi
import tech.kadence.core.KIND_LABEL
import tech.kadence.core.Kind
import tech.kadence.core.clock
import tech.kadence.core.dialSpentTicks
import tech.kadence.core.dialTickCount
object Notifications {
const val CHANNEL_TIMER = "timer"
const val CHANNEL_ALARM = "alarm"
/** The foreground service's notification. Stable, because it is replaced 1 Hz. */
const val ID_TIMER = 1001
const val ID_ALARM = 1002
fun ensureChannels(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java) ?: return
val timer = NotificationChannel(
CHANNEL_TIMER,
context.getString(R.string.channel_timer_name),
// LOW, not MIN: MIN would deny it the status-bar chip, which is the whole
// point of promoting it.
NotificationManager.IMPORTANCE_LOW,
).apply {
description = context.getString(R.string.channel_timer_description)
setShowBadge(false)
setSound(null, null)
enableVibration(false)
}
val alarm = NotificationChannel(
CHANNEL_ALARM,
context.getString(R.string.channel_alarm_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = context.getString(R.string.channel_alarm_description)
enableVibration(true)
vibrationPattern = longArrayOf(0, 220, 160, 220)
setSound(
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION),
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
.build(),
)
}
manager.createNotificationChannel(timer)
manager.createNotificationChannel(alarm)
}
/**
* The live countdown.
*
* Built fresh every tick rather than mutated, because `ProgressStyle` carries its
* segments by value and a reused builder accumulates them.
*/
fun timerNotification(context: Context, ui: TimerUi): Notification {
val label = KIND_LABEL[ui.kind] ?: ui.kind
val remaining = clock(ui.remaining)
val builder = NotificationCompat.Builder(context, CHANNEL_TIMER)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(if (ui.isPaused) "$label — paused" else label)
.setContentText(subtitle(ui))
.setContentIntent(openApp(context))
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
// The status-bar chip. Six characters or so — the clock, nothing else.
.setShortCriticalText(remaining)
.setRequestPromotedOngoing(true)
.setStyle(progressStyle(context, ui))
if (ui.isActive) {
builder.addAction(
if (ui.isPaused) {
action(context, R.string.action_resume, TimerService.ACTION_RESUME)
} else {
action(context, R.string.action_pause, TimerService.ACTION_PAUSE)
},
)
builder.addAction(action(context, R.string.action_extend, TimerService.ACTION_EXTEND))
builder.addAction(action(context, R.string.action_finish, TimerService.ACTION_COMPLETE))
}
return builder.build()
}
/**
* The dial, as a progress bar.
*
* Segmented rather than one continuous bar, and with the **same tick count** the
* Compose dial and the Glyph ring use — `dialTickCount` in `:core`. The front of
* the phone, the back of the phone and the status bar then all agree about what one
* mark is worth, which is the only reason to put the arithmetic in `:core` at all.
*/
private fun progressStyle(
context: Context,
ui: TimerUi,
): NotificationCompat.ProgressStyle {
val style = NotificationCompat.ProgressStyle()
.setStyledByProgress(false)
.setProgressTrackerIcon(
androidx.core.graphics.drawable.IconCompat.createWithResource(
context,
R.drawable.ic_notification,
),
)
val planned = ui.plannedSeconds
if (planned <= 0) {
return style.setProgressIndeterminate(true)
}
// One segment per dial tick. Segment lengths are in the same arbitrary unit as
// `setProgress`, so using seconds-per-tick keeps the maths exact and avoids a
// rounding drift that would leave the tracker a pixel off at the end.
val ticks = dialTickCount(planned)
val perTick = planned / ticks
repeat(ticks) {
style.addProgressSegment(NotificationCompat.ProgressStyle.Segment(perTick))
}
style.setProgress(dialSpentTicks(planned, ui.remaining) * perTick)
return style
}
private fun subtitle(ui: TimerUi): String {
val remaining = clock(ui.remaining)
val task = ui.taskTitle
return when {
!ui.isActive -> "Nothing running · ${ui.completedToday}/${ui.goal} today"
task != null -> "$remaining left · $task"
ui.isWork -> "$remaining left · ${ui.completedToday}/${ui.goal} today"
else -> "$remaining left"
}
}
/**
* The interval ended.
*
* Posted on the loud channel with the next interval one tap away. `auto_start` may
* already have begun the next one server-side, in which case this says so rather
* than offering to start a second.
*/
fun finishedNotification(
context: Context,
finishedKind: String,
autoStartedKind: String?,
celebration: String?,
completedToday: Int,
goal: Int,
): Notification {
val finishedLabel = KIND_LABEL[finishedKind] ?: finishedKind
val wasWork = finishedKind == Kind.WORK
val title = if (wasWork) "$finishedLabel done — $completedToday/$goal" else "$finishedLabel over"
val text = when {
autoStartedKind != null ->
"${KIND_LABEL[autoStartedKind] ?: autoStartedKind} has started."
wasWork -> "Time for a break."
else -> "Back to it."
}
val builder = NotificationCompat.Builder(context, CHANNEL_ALARM)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(openApp(context))
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setPriority(NotificationCompat.PRIORITY_HIGH)
// The celebration is Telegram's text, HTML and all; it reads badly in a
// notification, so only its presence is used — as a longer body line.
if (celebration != null && wasWork) {
builder.setStyle(
NotificationCompat.BigTextStyle().bigText("$text\n\nThat is $completedToday today. Keep the rhythm."),
)
}
// Nothing to offer if the server already rolled into the next interval.
if (autoStartedKind == null) {
builder.addAction(
if (wasWork) {
action(context, R.string.action_start_break, TimerService.ACTION_START_NEXT)
} else {
action(context, R.string.action_start_focus, TimerService.ACTION_START_NEXT)
},
)
}
return builder.build()
}
fun cancelAlarm(context: Context) {
NotificationManagerCompat.from(context).cancel(ID_ALARM)
}
/** Whether the user has actually granted the runtime permission. */
fun permitted(context: Context): Boolean =
NotificationManagerCompat.from(context).areNotificationsEnabled()
// ---- intents ------------------------------------------------------------
private fun action(
context: Context,
labelRes: Int,
serviceAction: String,
): NotificationCompat.Action = NotificationCompat.Action.Builder(
null,
context.getString(labelRes),
serviceIntent(context, serviceAction),
).build()
private fun serviceIntent(context: Context, action: String): PendingIntent {
val intent = Intent(context, TimerService::class.java).setAction(action)
return PendingIntent.getService(
context,
action.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun openApp(context: Context): PendingIntent {
val intent = Intent(context, MainActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
return PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/** Only used by the service's type declaration; kept here with the rest. */
val supportsPromotedOngoing: Boolean get() = Build.VERSION.SDK_INT >= 36
}
@@ -0,0 +1,200 @@
/**
* The foreground service: what keeps the countdown true while the app is not on screen.
*
* It owns no state. [TimerEngine] is a process-wide singleton on `KadenceApplication`,
* and this service subscribes to it exactly as the Compose UI does — so the
* notification, the dial and the rear matrix cannot disagree about the time, which they
* would if each polled separately.
*
* What it actually provides, none of which an Activity can:
*
* * **The process stays alive.** Without a foreground service Android is free to kill
* the app the moment it leaves the screen, and a pomodoro that stops counting when
* you switch to the thing you are working on is useless.
* * **The Live Update.** A promoted ongoing notification needs a persistent poster.
* * **The notification actions work when nothing is on screen.** Pause/Finish arrive
* here as `onStartCommand` intents.
*
* It starts itself when an interval becomes active and stops when none is, so there is
* no permanently-running service: the usual case — no interval — costs nothing.
*
* `FOREGROUND_SERVICE_TYPE_SPECIAL_USE` is the honest declaration. A pomodoro is not
* `mediaPlayback`, not `location`, not `dataSync`; Android 14+ requires a type and
* requires it to be accurate, with a `PROPERTY_SPECIAL_USE_FGS_SUBTYPE` explaining
* why — see the manifest.
*/
package tech.kadence.android.service
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import tech.kadence.android.KadenceApplication
import tech.kadence.android.glyph.GlyphPresenter
import tech.kadence.android.notify.Notifications
import tech.kadence.android.timer.TimerEvent
import tech.kadence.android.timer.TimerUi
private const val TAG = "Kadence"
class TimerService : LifecycleService() {
private val app: KadenceApplication get() = application as KadenceApplication
private lateinit var glyph: GlyphPresenter
private var started = false
private var lastPosted: String? = null
override fun onCreate() {
super.onCreate()
Notifications.ensureChannels(this)
glyph = GlyphPresenter(app.glyphDisplay)
glyph.connect()
// The engine is already running (the Application starts it); this only
// subscribes. `collectLatest` is deliberate: if a tick arrives while the
// previous notification is still being built, the stale one is abandoned.
lifecycleScope.launch {
app.engine.state.collectLatest { ui -> onState(ui) }
}
lifecycleScope.launch {
app.engine.events.collect { event -> onEvent(event) }
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
// Promote immediately. Android gives a service a few seconds to call
// startForeground or it is killed with a ForegroundServiceDidNotStartInTime
// crash — and that window is shorter than one poll of the backend, so the
// first notification has to be postable from whatever state we have now.
promote(app.engine.state.value)
when (intent?.action) {
ACTION_PAUSE -> app.engine.pause()
ACTION_RESUME -> app.engine.resume()
ACTION_COMPLETE -> app.engine.complete()
ACTION_EXTEND -> app.engine.extend(5)
ACTION_START_NEXT -> {
Notifications.cancelAlarm(this)
app.engine.startNext()
}
ACTION_STOP -> stopSelf()
}
return START_STICKY
}
private fun onState(ui: TimerUi) {
glyph.render(ui, app.prefs.current.glyphEnabled)
if (!ui.isActive) {
// Nothing running: drop the ongoing notification and let the process go.
// The engine keeps polling from the Application scope while the app is
// open, so stopping here loses nothing.
if (started) {
Log.d(TAG, "no active interval — leaving the foreground")
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
started = false
lastPosted = null
stopSelf()
}
return
}
promote(ui)
}
private fun promote(ui: TimerUi) {
if (!Notifications.permitted(this) && started) return
val notification = Notifications.timerNotification(this, ui)
if (!started) {
ServiceCompat.startForeground(
this,
Notifications.ID_TIMER,
notification,
if (Build.VERSION.SDK_INT >= 34) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
0
},
)
started = true
return
}
// Re-posting an identical notification makes the system redraw for nothing,
// and on the lock screen it is visible as a flicker.
val key = "${ui.remaining}:${ui.status}:${ui.kind}:${ui.taskTitle}"
if (key == lastPosted) return
lastPosted = key
NotificationManagerCompat.from(this).notify(Notifications.ID_TIMER, notification)
}
private fun onEvent(event: TimerEvent) {
if (event !is TimerEvent.Finished) return
if (!app.prefs.current.alarmOnComplete) return
NotificationManagerCompat.from(this).notify(
Notifications.ID_ALARM,
Notifications.finishedNotification(
context = this,
finishedKind = event.kind,
autoStartedKind = event.autoStartedKind,
celebration = event.celebration,
completedToday = event.completedToday,
goal = event.goal,
),
)
}
override fun onDestroy() {
glyph.release()
super.onDestroy()
}
/** Nothing binds to this; the engine is reached through the Application. */
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
return null
}
companion object {
const val ACTION_PAUSE = "tech.kadence.android.PAUSE"
const val ACTION_RESUME = "tech.kadence.android.RESUME"
const val ACTION_COMPLETE = "tech.kadence.android.COMPLETE"
const val ACTION_EXTEND = "tech.kadence.android.EXTEND"
const val ACTION_START_NEXT = "tech.kadence.android.START_NEXT"
const val ACTION_STOP = "tech.kadence.android.STOP"
/**
* Bring the service up for an interval that is now running.
*
* Safe to call repeatedly — a second `startForegroundService` on a running
* service is just another `onStartCommand`. Called from the Application
* whenever the engine reports an active interval, rather than from the UI, so
* it also happens when the interval was started on the desktop or in Telegram.
*/
fun ensureRunning(context: Context) {
val intent = Intent(context, TimerService::class.java)
runCatching { context.startForegroundService(intent) }
.onFailure { Log.w(TAG, "could not start the timer service: ${it.message}") }
}
fun stop(context: Context) {
context.startService(
Intent(context, TimerService::class.java).setAction(ACTION_STOP),
)
}
}
}
@@ -0,0 +1,427 @@
/**
* The one live view of the timer, shared by the UI, the foreground service and the
* rear display.
*
* There is exactly one of these per process (built in `KadenceApplication`), and that
* matters: if the Compose screen and `TimerService` each polled and ticked separately
* they would disagree by a second or two and the notification would visibly lag the
* dial. Instead both read this [state].
*
* The division of labour is the same as every other Kadence client:
*
* * **the server owns the clock.** Nothing here decides when an interval ends; it
* reports what the server last said and counts down from it.
* * **the tick is local and monotonic.** Between polls the countdown comes from
* `remainingAt()` against `System.nanoTime()`, so an NTP correction or a timezone
* change mid-interval cannot corrupt it.
* * **the poll reconciles.** Every reply goes through `:core`'s [reconcile], which
* decides whether to adopt the server's answer, keep an optimistic local value, or
* tell the user that another client took over.
*
* The one piece of real behaviour: `pomodoro.state()` does **not** finish an interval
* whose time is up — it just reports `remaining_seconds = 0`. A client has to call
* `complete`. `desktop/kadence_desktop/controller.py` does the same, with the same
* re-entry guard, because without one a 1 Hz tick at zero fires `complete` every second
* until the reply lands.
*/
package tech.kadence.android.timer
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.dto.TimerSessionDto
import tech.kadence.android.data.dto.TimerStateDto
import tech.kadence.android.data.net.ApiResult
import tech.kadence.core.Kind
import tech.kadence.core.LocalBelief
import tech.kadence.core.PendingOps
import tech.kadence.core.Reconciliation
import tech.kadence.core.ServerTimerState
import tech.kadence.core.Status
import tech.kadence.core.TickAnchor
import tech.kadence.core.TimerSessionFacts
import tech.kadence.core.progressOf
import tech.kadence.core.reconcile
import tech.kadence.core.remainingAt
private const val TAG = "Kadence"
/** How the backend is answering, if at all. */
enum class Connection { Unconfigured, Connecting, Online, Offline, Unauthorized }
/** Everything a screen, a notification or the Glyph needs, in one snapshot. */
data class TimerUi(
val connection: Connection = Connection.Unconfigured,
val sessionId: Long? = null,
val kind: String = Kind.WORK,
val status: String = Status.COMPLETED,
val remaining: Int = 0,
val plannedSeconds: Int = 0,
val interruptions: Int = 0,
val taskTitle: String? = null,
val completedToday: Int = 0,
val goal: Int = 8,
val cyclePosition: Int = 0,
val cyclesBeforeLongBreak: Int = 4,
val nextKind: String = Kind.WORK,
val durationsMinutes: Map<String, Int> = emptyMap(),
val timerEnabled: Boolean = true,
/** A command is in flight; the primary button should not be pressed twice. */
val busy: Boolean = false,
/** Transient, user-facing — a conflict, a rejection, a reconnection. */
val notice: String? = null,
) {
val isActive: Boolean get() = sessionId != null &&
(status == Status.RUNNING || status == Status.PAUSED)
val isPaused: Boolean get() = status == Status.PAUSED
val isWork: Boolean get() = kind == Kind.WORK
val progress: Float get() = progressOf(remaining, plannedSeconds)
}
/** Things worth reacting to once, rather than rendering continuously. */
sealed interface TimerEvent {
/** An interval just finished. Carries what ended and what (if anything) began. */
data class Finished(
val kind: String,
val autoStartedKind: String?,
val celebration: String?,
val completedToday: Int,
val goal: Int,
) : TimerEvent
data class Failed(val detail: String) : TimerEvent
}
class TimerEngine(
private val repo: KadenceRepository,
private val scope: CoroutineScope,
private val configured: () -> Boolean,
) {
private val _state = MutableStateFlow(TimerUi())
val state: StateFlow<TimerUi> = _state.asStateFlow()
private val _events = MutableSharedFlow<TimerEvent>(extraBufferCapacity = 8)
val events: SharedFlow<TimerEvent> = _events.asSharedFlow()
private var anchor: TickAnchor? = null
/**
* Serialises commands against each other and against the poll.
*
* Without it, a tap on "Finish" and a poll landing in the same millisecond can
* apply their replies in either order, and the older one wins — the dial jumps
* back to a finished interval.
*/
private val mutex = Mutex()
/** The `complete` re-entry guard. See the file header. */
private var completing = false
private var pollJob: Job? = null
private var tickJob: Job? = null
// ---- lifecycle ----------------------------------------------------------
fun start() {
if (pollJob?.isActive != true) {
pollJob = scope.launch { pollLoop() }
}
if (tickJob?.isActive != true) {
tickJob = scope.launch { tickLoop() }
}
}
fun stop() {
pollJob?.cancel()
tickJob?.cancel()
pollJob = null
tickJob = null
}
/** Ask for a fresh poll now — on app foreground, or after a settings change. */
fun refreshNow() = scope.launch { poll() }
// ---- the loops ----------------------------------------------------------
private suspend fun pollLoop() {
while (scope.isActive) {
poll()
// An active interval is worth watching closely: it is how this phone
// notices the desktop app pausing it, or the interval ending elsewhere.
// Idle, there is nothing to miss, and the radio should sleep.
delay(if (_state.value.isActive) ACTIVE_POLL_MS else IDLE_POLL_MS)
}
}
private suspend fun tickLoop() {
while (scope.isActive) {
delay(250)
val current = anchor ?: continue
val remaining = remainingAt(current, System.nanoTime())
if (remaining != _state.value.remaining) {
_state.value = _state.value.copy(remaining = remaining)
}
if (remaining == 0 && _state.value.isActive && !current.paused && !completing) {
// The countdown has run out. The server books the interval and its
// reply decides what follows — an auto-started break, or nothing.
completing = true
complete()
}
}
}
// ---- polling and reconciliation ----------------------------------------
private suspend fun poll() {
if (!configured()) {
_state.value = _state.value.copy(connection = Connection.Unconfigured)
return
}
if (_state.value.connection == Connection.Unconfigured) {
_state.value = _state.value.copy(connection = Connection.Connecting)
}
when (val result = repo.timerState()) {
is ApiResult.Ok -> mutex.withLock { applyServer(result.value, fromCommand = false) }
is ApiResult.Unauthorized ->
_state.value = _state.value.copy(connection = Connection.Unauthorized)
is ApiResult.NetworkError -> {
Log.d(TAG, "poll offline: ${result.cause.message}")
_state.value = _state.value.copy(connection = Connection.Offline)
}
is ApiResult.HttpError -> {
Log.w(TAG, "poll failed: ${result.status} ${result.detail}")
_state.value = _state.value.copy(
connection = Connection.Online,
notice = result.detail,
)
}
}
}
/**
* Fold a server reply into [state].
*
* @param fromCommand true when this is the reply to something the user just did,
* which is the one case where a difference from the local belief is *expected*
* and must not be narrated as somebody else's doing.
*/
private suspend fun applyServer(dto: TimerStateDto, fromCommand: Boolean) {
val server = dto.toCore()
val local = LocalBelief(
sessionId = _state.value.sessionId,
paused = _state.value.isPaused,
)
// A command's reply *is* our own intent landing, so it is declared as pending
// for the reconciler's benefit. Nothing is queued on disk yet: the offline
// outbox in `:core` is wired up by the service, not here.
val pending = if (fromCommand) {
PendingOps(hasAny = true, hasStart = true, hasTerminal = true)
} else {
PendingOps()
}
val decision = reconcile(server, local, pending)
// Only *events* become notices — something happened once, elsewhere, and the
// user should be told. A standing *condition* is rendered from the state it
// describes instead: `ViewOnlyPurge` deliberately produces no notice, because
// the timer being switched off is already a banner driven by `timerEnabled`,
// and a notice would both duplicate it and outlive it (notices persist until
// dismissed, so switching the timer back on would leave the stale sentence
// sitting under the live one).
val notice = when (decision) {
is Reconciliation.AdoptForeign ->
"Another device started a ${labelOf(decision.kind).lowercase()}."
Reconciliation.EndedElsewhere -> "That interval was ended on another device."
Reconciliation.PausedElsewhere -> "Paused on another device."
else -> null
}
if (decision == Reconciliation.KeepOptimistic) {
// Re-anchoring now would visibly bounce the dial; keep what we show and
// take the counters, which are not what is in dispute.
_state.value = _state.value.copy(
connection = Connection.Online,
completedToday = server.completedToday,
goal = server.goal,
)
return
}
val current = server.current
if (current == null) {
anchor = null
completing = false
} else {
val changed = anchor?.sessionId != current.id ||
anchor?.paused != current.isPaused ||
// Re-anchor when the server and our tick have drifted apart by more
// than a second — an extend, a pause elsewhere, or a slow doze.
kotlin.math.abs(
remainingAt(anchor!!, System.nanoTime()) - current.remainingSeconds,
) > 1
if (changed) {
anchor = TickAnchor(
sessionId = current.id,
remainingAtAnchor = current.remainingSeconds,
elapsedNanos = System.nanoTime(),
paused = current.isPaused,
)
}
if (current.id != _state.value.sessionId) completing = false
}
_state.value = _state.value.copy(
connection = Connection.Online,
sessionId = current?.id,
kind = current?.kind ?: server.nextKind,
status = current?.status ?: Status.COMPLETED,
remaining = current?.remainingSeconds ?: 0,
plannedSeconds = current?.plannedSeconds ?: 0,
interruptions = current?.interruptions ?: 0,
taskTitle = dto.current?.task?.title,
completedToday = server.completedToday,
goal = server.goal,
cyclePosition = server.cyclePosition,
cyclesBeforeLongBreak = server.cyclesBeforeLongBreak,
nextKind = server.nextKind,
durationsMinutes = server.durationsMinutes,
timerEnabled = server.timerEnabled,
notice = notice ?: _state.value.notice,
)
}
// ---- commands -----------------------------------------------------------
fun startWork(minutes: Int? = null, taskId: Long? = null) =
command { repo.start(kind = Kind.WORK, minutes = minutes, taskId = taskId) }
fun startKind(kind: String, minutes: Int? = null) =
command { repo.start(kind = kind, minutes = minutes) }
fun startNext() = command { repo.start(kind = _state.value.nextKind) }
fun pause() = command { repo.pause() }
fun resume() = command { repo.resume() }
fun interrupt() = command { repo.interrupt() }
fun extend(minutes: Int = 5) = command { repo.extend(minutes) }
fun attachTask(taskId: Long?) = command { repo.attachTask(taskId) }
fun abandon(reason: String? = null) = command { repo.abandon(reason) }
fun toggle() {
val ui = _state.value
when {
!ui.isActive -> startNext()
ui.isPaused -> resume()
else -> pause()
}
}
/** `complete` is not in [command] because its reply carries the finish event. */
fun complete(autoNext: Boolean = true) = scope.launch {
mutex.withLock {
_state.value = _state.value.copy(busy = true)
when (val result = repo.complete(autoNext)) {
is ApiResult.Ok -> {
val dto = result.value
applyServer(dto, fromCommand = true)
dto.finished?.let { finished ->
_events.tryEmit(
TimerEvent.Finished(
kind = finished.kind,
autoStartedKind = dto.autoStarted?.kind,
celebration = dto.celebration,
completedToday = dto.completedToday,
goal = dto.goal,
),
)
}
}
else -> handleFailure(result)
}
_state.value = _state.value.copy(busy = false)
completing = false
}
}
private fun command(call: suspend () -> ApiResult<TimerStateDto>) = scope.launch {
mutex.withLock {
_state.value = _state.value.copy(busy = true, notice = null)
when (val result = call()) {
is ApiResult.Ok -> applyServer(result.value, fromCommand = true)
else -> handleFailure(result)
}
_state.value = _state.value.copy(busy = false)
}
}
private fun handleFailure(result: ApiResult<*>) {
val detail = when (result) {
// A 400 here is the server refusing on the merits — "No interval is
// running", or strict mode wanting a reason. Worth showing verbatim.
is ApiResult.HttpError -> result.detail
is ApiResult.Unauthorized -> {
_state.value = _state.value.copy(connection = Connection.Unauthorized)
result.detail
}
is ApiResult.NetworkError -> {
_state.value = _state.value.copy(connection = Connection.Offline)
"No connection to the backend."
}
else -> "Something went wrong."
}
_state.value = _state.value.copy(notice = detail)
_events.tryEmit(TimerEvent.Failed(detail))
}
fun clearNotice() {
_state.value = _state.value.copy(notice = null)
}
private companion object {
const val ACTIVE_POLL_MS = 10_000L
const val IDLE_POLL_MS = 45_000L
}
}
/** The DTO reduced to the facts `:core` reasons about. */
private fun TimerStateDto.toCore(): ServerTimerState = ServerTimerState(
current = current?.toFacts(),
nextKind = nextKind,
completedToday = completedToday,
goal = goal,
cyclePosition = cyclePosition,
cyclesBeforeLongBreak = cyclesBeforeLongBreak,
durationsMinutes = durations,
timerEnabled = timerEnabled,
)
private fun TimerSessionDto.toFacts(): TimerSessionFacts = TimerSessionFacts(
id = id,
kind = kind,
status = status,
plannedSeconds = plannedSeconds,
remainingSeconds = remainingSeconds,
pausedSeconds = pausedSeconds,
interruptions = interruptions,
)
private fun labelOf(kind: String): String = tech.kadence.core.KIND_LABEL[kind] ?: kind
@@ -0,0 +1,216 @@
/**
* The shell: four destinations and the bar that switches them.
*
* Onboarding is handled by swapping the whole shell out rather than as a fifth
* destination, because until a backend answers there is nothing for the other three to
* show — a bottom bar over four empty screens is worse than one screen that explains
* what is missing.
*
* The timer state is hoisted to here (collected once from the engine) rather than
* collected inside each screen. Two screens reading the same StateFlow would both
* recompose on every tick; this way the tick reaches the dial, which reads it in its
* draw phase, and nothing else re-lays out.
*/
package tech.kadence.android.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.selection.selectable
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import tech.kadence.android.KadenceApplication
import tech.kadence.android.data.dto.AppSettingsDto
import tech.kadence.android.data.net.ApiResult
import tech.kadence.android.ui.components.HorizontalRule
import tech.kadence.android.ui.onboarding.OnboardingScreen
import tech.kadence.android.ui.report.ReportScreen
import tech.kadence.android.ui.settings.SettingsScreen
import tech.kadence.android.ui.tasks.TasksScreen
import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.android.ui.timer.TimerScreen
private enum class Destination(val label: String) {
Timer("Timer"),
Tasks("Tasks"),
Report("Today"),
Settings("Settings"),
}
@Composable
fun KadenceApp(app: KadenceApplication) {
val config by app.prefs.config.collectAsStateWithLifecycle()
val ui by app.engine.state.collectAsStateWithLifecycle()
var showOnboarding by remember { mutableStateOf(!config.onboarded) }
var destination by remember { mutableStateOf(Destination.Timer) }
val scope = rememberCoroutineScope()
// The server's named rhythms, and which one the current lengths match. Loaded
// once and re-read after a write, rather than polled: settings change rarely and
// the timer poll is already carrying the lengths that actually matter.
var settings by remember { mutableStateOf<AppSettingsDto?>(null) }
suspend fun loadSettings() {
(app.repo.settings() as? ApiResult.Ok)?.let { settings = it.value.settings }
}
LaunchedEffect(config.baseUrl, config.onboarded, showOnboarding) {
if (config.onboarded && !showOnboarding) loadSettings()
}
val presets = settings?.pomodoro?.presets.orEmpty()
// "Active" means the live durations equal the preset's, not that anyone selected
// it — the lengths can also be edited one at a time in the web UI, and then no
// preset is active. Compared against the *timer state's* durations because those
// are what the server is really using.
val activePresetName = presets.firstOrNull { preset ->
ui.durationsMinutes["work"] == preset.workMinutes &&
ui.durationsMinutes["short_break"] == preset.shortBreakMinutes &&
ui.durationsMinutes["long_break"] == preset.longBreakMinutes &&
ui.cyclesBeforeLongBreak == preset.cyclesBeforeLongBreak
}?.name
Surface(Modifier.fillMaxSize(), color = KadenceTheme.colors.paper) {
if (showOnboarding) {
Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
OnboardingScreen(
config = config,
repo = app.repo,
onSave = { next -> app.prefs.update { next } },
onDone = {
showOnboarding = false
// The engine was refusing to poll while unconfigured; now that
// it is, ask straight away rather than waiting out the interval.
app.engine.refreshNow()
},
)
}
return@Surface
}
Column(Modifier.fillMaxSize().statusBarsPadding()) {
Box(Modifier.weight(1f)) {
when (destination) {
Destination.Timer -> TimerScreen(
ui = ui,
presets = presets,
activePresetName = activePresetName,
onApplyPreset = { preset ->
scope.launch {
app.repo.applyPreset(preset)
loadSettings()
// The durations live in the timer state, so the dial
// and the chips only agree again after a fresh poll.
app.engine.refreshNow()
}
},
onStart = { minutes -> app.engine.startWork(minutes) },
onToggle = app.engine::toggle,
onComplete = { app.engine.complete() },
onAbandon = { reason -> app.engine.abandon(reason) },
onExtend = { app.engine.extend(5) },
onInterrupt = app.engine::interrupt,
onDismissNotice = app.engine::clearNotice,
onOpenSettings = { destination = Destination.Settings },
)
Destination.Tasks -> TasksScreen(
repo = app.repo,
runningTaskTitle = ui.taskTitle,
intervalRunning = ui.isActive,
onAttach = { taskId ->
// `attach` needs something to attach to: with nothing
// running the server answers "No interval is running."
// and the tap achieves a red banner. Starting a focus
// interval on that task is what was meant either way.
if (ui.isActive) {
app.engine.attachTask(taskId)
} else {
app.engine.startWork(taskId = taskId)
}
destination = Destination.Timer
},
)
Destination.Report -> ReportScreen(repo = app.repo)
Destination.Settings -> SettingsScreen(
config = config,
ui = ui,
onUpdate = { transform -> app.prefs.update(transform) },
onSetTimerEnabled = { enabled ->
scope.launch {
app.repo.setTimerEnabled(enabled)
loadSettings()
app.engine.refreshNow()
}
},
onReconnect = { showOnboarding = true },
)
}
}
HorizontalRule()
BottomBar(
current = destination,
onSelect = { destination = it },
modifier = Modifier.navigationBarsPadding(),
)
}
}
}
/**
* Hand-rolled rather than `NavigationBar`.
*
* M3's bar brings a tonal surface, an active-indicator pill and 80dp of height — three
* things this design system does not have anywhere else. A row of labels over a
* hairline is what the web app's nav is, and it costs twenty lines.
*/
@Composable
private fun BottomBar(
current: Destination,
onSelect: (Destination) -> Unit,
modifier: Modifier = Modifier,
) {
val colors = KadenceTheme.colors
Row(
modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Destination.entries.forEach { entry ->
val selected = entry == current
Box(
Modifier
.weight(1f)
.selectable(selected = selected, onClick = { onSelect(entry) })
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = entry.label,
style = if (selected) KadenceTheme.type.button else KadenceTheme.type.bodySmall,
color = if (selected) colors.accent else colors.inkMuted,
)
}
}
}
}
@@ -0,0 +1,65 @@
/**
* A fetch, as four states a screen can render.
*
* Every read-only screen here does the same thing: call one endpoint, show a spinner,
* then show either the thing or why not. Written once because the interesting case is
* the one that is easy to skip — a *reload* that fails should keep showing the stale
* data with an error beside it, not blank the screen. [Loadable.Loaded] therefore
* carries an optional `staleError`, and [rememberLoad] never throws away a value it
* already has.
*/
package tech.kadence.android.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import tech.kadence.android.data.net.ApiResult
sealed interface Loadable<out T> {
data object Idle : Loadable<Nothing>
data object Loading : Loadable<Nothing>
data class Loaded<T>(val value: T, val staleError: String? = null) : Loadable<T>
data class Failed(val detail: String) : Loadable<Nothing>
}
/** Drives a [Loadable] from a suspending call, with a `reload` to hand back. */
class Loader<T>(
private val state: MutableState<Loadable<T>>,
private val scope: kotlinx.coroutines.CoroutineScope,
private val call: suspend () -> ApiResult<T>,
) {
val value: Loadable<T> get() = state.value
fun reload() {
val existing = state.value
state.value = if (existing is Loadable.Loaded) existing else Loadable.Loading
scope.launch {
state.value = when (val result = call()) {
is ApiResult.Ok -> Loadable.Loaded(result.value)
is ApiResult.HttpError -> degrade(existing, result.detail)
is ApiResult.Unauthorized -> degrade(existing, "Not signed in.")
is ApiResult.NetworkError -> degrade(existing, "No connection to the backend.")
}
}
}
/** Keep what we had, if we had anything — a failed refresh is not an empty screen. */
private fun degrade(previous: Loadable<T>, detail: String): Loadable<T> =
if (previous is Loadable.Loaded) previous.copy(staleError = detail) else Loadable.Failed(detail)
}
@Composable
fun <T> rememberLoad(vararg keys: Any?, call: suspend () -> ApiResult<T>): Loader<T> {
val state = remember { mutableStateOf<Loadable<T>>(Loadable.Idle) }
val scope = rememberCoroutineScope()
val loader = remember(*keys) { Loader(state, scope, call) }
androidx.compose.runtime.LaunchedEffect(loader) {
if (state.value == Loadable.Idle) loader.reload()
}
return loader
}
@@ -9,6 +9,7 @@ package tech.kadence.android.ui.components
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -16,17 +17,23 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -263,3 +270,84 @@ fun PresetChip(
} }
} }
} }
/**
* The inline warning strip, ported from `qml/components/Banner.qml`.
*
* Clay at 12% on a 40% border — loud enough to read as a problem, quiet enough that
* three of them stacked do not look like a crash. Used for the states where the app
* cannot do its job: no backend, rejected credentials, notifications switched off.
*/
@Composable
fun Banner(
text: String,
modifier: Modifier = Modifier,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
) {
val colors = KadenceTheme.colors
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = colors.clay.copy(alpha = 0.12f),
border = BorderStroke(1.dp, colors.clay.copy(alpha = 0.40f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = text,
style = KadenceTheme.type.caption,
color = colors.ink,
modifier = Modifier.weight(1f),
)
if (actionLabel != null && onAction != null) {
TextButton(onClick = onAction, contentPadding = PaddingValues(horizontal = 8.dp)) {
Text(
text = actionLabel,
style = KadenceTheme.type.button,
color = colors.accent,
)
}
}
}
}
}
/**
* Today's pomodoros as a row of dots — the web app's `TimerPage.tsx` encoding:
* earned dots filled in the work colour, the rest a hairline ring.
*
* The row length is `max(goal, done)`, so beating the goal grows the row rather than
* silently capping it. It wraps, because a 12-pomodoro day on a phone does not fit.
*/
@Composable
fun DotRow(done: Int, goal: Int, modifier: Modifier = Modifier) {
val colors = KadenceTheme.colors
val work = KadenceTheme.viz.work
FlowRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
repeat(maxOf(goal, done, 1)) { index ->
val earned = index < done
Box(
Modifier
.size(11.dp)
.clip(CircleShape)
.then(
if (earned) {
Modifier.background(work)
} else {
Modifier.border(1.dp, colors.rule, CircleShape)
},
),
)
}
}
}
@@ -0,0 +1,262 @@
/**
* Pointing the phone at a backend.
*
* The first screen on a fresh install, and the only one that can talk to a server it
* has not yet been told about. Two fields, because there are exactly two things the
* app cannot guess: where the backend is, and the shared secret it wants.
*
* It **verifies before it saves**. A URL that is merely stored looks identical to one
* that works until the timer screen comes up empty, and the most common mistakes here
* are not typos — they are an always-on VPN swallowing a LAN address, or a missing
* `X-API-Token` on a backend that has one set. So the Connect button actually calls
* `/api/health` and then `/api/timer/state`, and reports which of the two failed:
* health failing is "wrong address", state failing with a 401 is "right address,
* wrong token". Those need different fixes and the distinction is worth the round trip.
*/
package tech.kadence.android.ui.onboarding
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.net.ApiResult
import tech.kadence.android.data.store.KadenceConfig
import tech.kadence.android.data.store.KadencePrefs
import tech.kadence.android.ui.components.Eyebrow
import tech.kadence.android.ui.components.GhostButton
import tech.kadence.android.ui.components.Panel
import tech.kadence.android.ui.components.PrimaryButton
import tech.kadence.android.ui.theme.KadenceTheme
@Composable
fun OnboardingScreen(
config: KadenceConfig,
repo: KadenceRepository,
onSave: (KadenceConfig) -> Unit,
onDone: () -> Unit,
) {
val colors = KadenceTheme.colors
val scope = rememberCoroutineScope()
var url by remember { mutableStateOf(config.baseUrl) }
var token by remember { mutableStateOf(config.apiToken.orEmpty()) }
var checking by remember { mutableStateOf(false) }
var problem by remember { mutableStateOf<String?>(null) }
var reassurance by remember { mutableStateOf<String?>(null) }
fun connect() {
val normalized = KadencePrefs.normalizeBaseUrl(url)
if (normalized.isBlank()) {
problem = "Give the address of your Kadence backend."
return
}
url = normalized
checking = true
problem = null
reassurance = null
// Saved first, because the repository reads the live config — there is no way
// to make a call "as if" configured. Onboarded stays false until it works, so
// a failed attempt leaves us on this screen rather than half-committed.
onSave(
config.copy(
baseUrl = normalized,
apiToken = token.takeIf { it.isNotBlank() },
onboarded = false,
),
)
scope.launch {
when (val health = repo.health()) {
is ApiResult.Ok -> Unit
is ApiResult.NetworkError -> {
checking = false
problem = "Nothing answered at $normalized.\n\n" +
"If the backend is on your LAN, check that an always-on VPN " +
"is not swallowing the address."
return@launch
}
is ApiResult.HttpError -> {
checking = false
problem = "The server answered ${health.status}: ${health.detail}\n\n" +
"That is something other than Kadence on this address."
return@launch
}
is ApiResult.Unauthorized -> Unit // health is public; fall through
}
// The address is right. Now: will it let us in?
when (val state = repo.timerState()) {
is ApiResult.Ok -> {
checking = false
onSave(
config.copy(
baseUrl = normalized,
apiToken = token.takeIf { it.isNotBlank() },
onboarded = true,
),
)
onDone()
}
is ApiResult.Unauthorized -> {
checking = false
problem = if (token.isBlank()) {
"Found Kadence, but it wants an API token.\n\n" +
"It is API_TOKEN in the backend's .env."
} else {
"Found Kadence, but it rejected that token."
}
reassurance = "The address is correct."
}
is ApiResult.HttpError -> {
checking = false
problem = "Kadence answered ${state.status}: ${state.detail}"
}
is ApiResult.NetworkError -> {
checking = false
problem = "Lost the connection partway through. Try again."
}
}
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.imePadding()
.padding(horizontal = 20.dp, vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Column {
Eyebrow("Kadence")
Text(
text = "Find the rhythm\nof your work day.",
style = KadenceTheme.type.pageTitle,
color = colors.ink,
modifier = Modifier.padding(top = 4.dp),
)
Text(
text = "The server owns the clock. Point this at it and the phone " +
"becomes another window onto the same interval.",
style = KadenceTheme.type.bodySmall,
color = colors.inkMuted,
modifier = Modifier.padding(top = 10.dp),
)
}
Panel(padding = 18.dp) {
Eyebrow("Backend")
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it; problem = null },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("kadence.timka.su", style = KadenceTheme.type.body) },
textStyle = KadenceTheme.type.body,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
),
enabled = !checking,
)
Text(
text = "https:// is assumed unless you type a LAN address.",
style = KadenceTheme.type.caption,
color = colors.inkFaint,
modifier = Modifier.padding(top = 6.dp),
)
Spacer(Modifier.height(16.dp))
Eyebrow("API token")
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = token,
onValueChange = { token = it; problem = null },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("optional", style = KadenceTheme.type.body) },
textStyle = KadenceTheme.type.mono,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
enabled = !checking,
)
Text(
text = "API_TOKEN from the backend's .env. Leave it empty if the " +
"backend has none set.",
style = KadenceTheme.type.caption,
color = colors.inkFaint,
modifier = Modifier.padding(top = 6.dp),
)
}
if (problem != null) {
Panel(padding = 14.dp) {
if (reassurance != null) {
Text(
text = reassurance!!,
style = KadenceTheme.type.caption,
color = colors.pine,
modifier = Modifier.padding(bottom = 4.dp),
)
}
Text(
text = problem!!,
style = KadenceTheme.type.bodySmall,
color = colors.clay,
)
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PrimaryButton(
text = if (checking) "Checking…" else "Connect",
onClick = ::connect,
enabled = !checking,
)
if (checking) {
CircularProgressIndicator(
modifier = Modifier.height(18.dp),
strokeWidth = 2.dp,
color = colors.accent,
)
}
Spacer(Modifier.fillMaxWidth(0.1f))
}
// There is no "skip": without a backend every screen is empty, and an app
// that lets you past this point only to show nothing is worse than one that
// insists. The escape hatch is that a wrong answer is editable in Settings.
if (config.onboarded) {
GhostButton(text = "Back", onClick = onDone)
}
}
}
@@ -0,0 +1,240 @@
/**
* Today, as the evening report tells it.
*
* The same `/api/reports/daily` payload the Telegram message is built from, so the
* numbers on the phone and the numbers in the 21:00 message are the same numbers by
* construction rather than by agreement.
*
* The one thing *not* shown is `telegram_preview`: it is HTML aimed at Telegram's
* renderer, and pasting it into a Compose `Text` shows the tags. The phone renders the
* structured fields instead and leaves the prose to Telegram.
*/
package tech.kadence.android.ui.report
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.dto.DailyReportEnvelopeDto
import tech.kadence.android.ui.Loadable
import tech.kadence.android.ui.components.Banner
import tech.kadence.android.ui.components.DotRow
import tech.kadence.android.ui.components.Eyebrow
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.SectionTitle
import tech.kadence.android.ui.components.Stat
import tech.kadence.android.ui.components.StatSize
import tech.kadence.android.ui.components.VerticalRule
import tech.kadence.android.ui.rememberLoad
import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.humanMinutes
@Composable
fun ReportScreen(repo: KadenceRepository) {
val colors = KadenceTheme.colors
val loader = rememberLoad<DailyReportEnvelopeDto> { repo.dailyReport() }
val state = loader.value
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
PageHeader(
eyebrow = (state as? Loadable.Loaded)?.value?.report?.date ?: "Today",
title = "The day so far",
caption = (state as? Loadable.Loaded)?.value?.report?.verdict?.takeIf {
it.isNotBlank()
},
) {
GhostButton(text = "Refresh", onClick = loader::reload)
}
}
when (state) {
Loadable.Idle, Loadable.Loading -> item {
Row(
Modifier.fillMaxWidth().padding(top = 40.dp),
horizontalArrangement = Arrangement.Center,
) { CircularProgressIndicator(strokeWidth = 2.dp, color = colors.accent) }
}
is Loadable.Failed -> item {
Banner(text = state.detail, actionLabel = "Retry", onAction = loader::reload)
}
is Loadable.Loaded -> {
val report = state.value.report
val m = report.metrics
state.staleError?.let { stale ->
item { Banner(text = stale, actionLabel = "Retry", onAction = loader::reload) }
}
item {
Panel(padding = 0.dp) {
Column(Modifier.padding(16.dp)) {
DotRow(done = m.pomodoros, goal = m.goal)
Spacer(Modifier.height(10.dp))
Text(
text = "${humanMinutes(m.focusMinutes)} focused · " +
"${humanMinutes(m.breakMinutes)} rested" +
if (m.longestStretchMinutes > 0) {
" · longest run ${humanMinutes(m.longestStretchMinutes)}"
} else {
""
},
style = KadenceTheme.type.bodySmall,
color = colors.inkMuted,
)
}
HorizontalRule()
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Stat(
label = "Pomodoros",
value = "${m.pomodoros}",
caption = "of ${m.goal}",
size = StatSize.Large,
accent = colors.accent,
tabular = true,
modifier = Modifier.weight(1f).padding(16.dp),
)
VerticalRule(height = 84.dp)
Stat(
label = "Score",
value = "${report.score.toInt()}",
caption = "out of 100",
size = StatSize.Large,
tabular = true,
modifier = Modifier.weight(1f).padding(16.dp),
)
}
HorizontalRule()
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Stat(
label = "Streak",
value = "${report.streak.current}d",
caption = "best ${report.streak.best}",
tabular = true,
modifier = Modifier.weight(1f).padding(16.dp),
)
VerticalRule(height = 70.dp)
Stat(
label = "7-day average",
value = String.format("%.1f", report.average7d.pomodoros),
caption = "yesterday ${report.comparison.yesterday}",
tabular = true,
modifier = Modifier.weight(1f).padding(16.dp),
)
}
HorizontalRule()
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Stat(
label = "Interruptions",
value = "${m.interruptions}",
caption = "counted, not stopped",
tabular = true,
modifier = Modifier.weight(1f).padding(16.dp),
)
VerticalRule(height = 70.dp)
Stat(
label = "Blocked",
value = "${m.blockedAttempts}",
caption = m.blockedTop.firstOrNull()?.target ?: "nothing tried",
tabular = true,
modifier = Modifier.weight(1f).padding(16.dp),
)
}
}
}
if (report.achievements.isNotEmpty()) {
item { SectionTitle("Achievements", trailing = "${report.achievements.size}") }
item {
Panel(padding = 0.dp) {
report.achievements.forEachIndexed { index, achievement ->
if (index > 0) HorizontalRule()
Row(
Modifier.fillMaxWidth().padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = achievement.emoji,
style = KadenceTheme.type.statSmall,
)
Column {
Text(
text = achievement.title,
style = KadenceTheme.type.body,
color = colors.ink,
)
Text(
text = achievement.detail,
style = KadenceTheme.type.caption,
color = colors.inkMuted,
)
}
}
}
}
}
}
if (m.tasksCompleted.isNotEmpty()) {
item { SectionTitle("Closed today", trailing = "${m.tasksCompletedCount}") }
item {
Panel(padding = 0.dp) {
m.tasksCompleted.forEachIndexed { index, task ->
if (index > 0) HorizontalRule()
Column(Modifier.fillMaxWidth().padding(14.dp)) {
Text(
text = task.title,
style = KadenceTheme.type.body,
color = colors.ink,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Eyebrow(task.project ?: task.source)
}
}
}
}
}
if (report.funFacts.isNotEmpty()) {
item { SectionTitle("Meanwhile") }
item {
Panel {
report.funFacts.forEachIndexed { index, fact ->
if (index > 0) Spacer(Modifier.height(8.dp))
Text(
text = fact,
style = KadenceTheme.type.bodySmall,
color = colors.inkMuted,
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,280 @@
/**
* The local settings — and only the local ones.
*
* Four toggles and the backend's address. Everything *else* a user might expect here
* (interval lengths, the daily goal, the block lists, the report schedule, the Telegram
* tone) lives in the settings document on the server and is edited in the web UI. That
* is not an omission: those values are shared by four clients, and a phone that kept
* its own copy would be a fifth answer to "how long is a focus interval".
*
* So this screen is honest about the split, and says where the rest is.
*/
package tech.kadence.android.ui.settings
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings as AndroidSettings
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.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import tech.kadence.android.BuildConfig
import tech.kadence.android.data.store.KadenceConfig
import tech.kadence.android.notify.Notifications
import tech.kadence.android.timer.Connection
import tech.kadence.android.timer.TimerUi
import tech.kadence.android.ui.components.Banner
import tech.kadence.android.ui.components.Eyebrow
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.SectionTitle
import tech.kadence.android.ui.theme.KadenceTheme
@Composable
fun SettingsScreen(
config: KadenceConfig,
ui: TimerUi,
onUpdate: ((KadenceConfig) -> KadenceConfig) -> Unit,
onSetTimerEnabled: (Boolean) -> Unit,
onReconnect: () -> Unit,
) {
val colors = KadenceTheme.colors
val context = LocalContext.current
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
PageHeader(
eyebrow = "This phone only",
title = "Settings",
caption = "Interval lengths, the goal, the block lists and the reports " +
"live on the server — they are shared by every client.",
)
SectionTitle("Backend")
Panel(padding = 0.dp) {
Column(Modifier.padding(14.dp)) {
Eyebrow("Address")
Text(
text = config.baseUrl,
style = KadenceTheme.type.mono,
color = colors.ink,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
Spacer(Modifier.height(6.dp))
Text(
text = when (ui.connection) {
Connection.Online -> "Connected."
Connection.Offline -> "Not answering."
Connection.Unauthorized -> "Rejected our credentials."
Connection.Connecting -> "Connecting…"
Connection.Unconfigured -> "Not configured."
},
style = KadenceTheme.type.caption,
color = when (ui.connection) {
Connection.Online -> colors.pine
Connection.Offline, Connection.Unauthorized -> colors.clay
else -> colors.inkMuted
},
)
Spacer(Modifier.height(4.dp))
Text(
text = if (config.apiToken.isNullOrBlank()) {
"No API token set."
} else {
// Never the token itself: this screen is the one place it would
// be shoulder-surfable, and seeing it serves no purpose.
"API token set (${config.apiToken.length} characters)."
},
style = KadenceTheme.type.caption,
color = colors.inkFaint,
)
}
HorizontalRule()
Row(
Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
GhostButton(text = "Change", onClick = onReconnect)
GhostButton(
text = "Open web UI",
onClick = {
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(config.baseUrl)),
)
}
},
)
}
}
// The one server-side setting that is here rather than in the web UI: it is
// the switch that makes every other control on the timer screen inert, so
// finding it from the phone matters. It writes to the shared settings
// document, which is why the caption says so out loud.
SectionTitle("Timer")
Panel(padding = 0.dp) {
ToggleRow(
label = "Timer enabled",
detail = "Off makes every client view-only — the phone, the web app " +
"and the desktop app all stop accepting start, pause and resume. " +
"This is a server setting, not a phone one.",
checked = ui.timerEnabled,
enabled = ui.connection == Connection.Online,
onChange = onSetTimerEnabled,
)
}
SectionTitle("Notifications")
if (!Notifications.permitted(context)) {
Banner(
text = "Notifications are blocked for Kadence, so there is no countdown " +
"in the status bar and no alarm when an interval ends.",
actionLabel = "System settings",
onAction = { openAppNotificationSettings(context) },
)
}
Panel(padding = 0.dp) {
ToggleRow(
label = "Ring when an interval ends",
detail = "A high-priority notification on its own channel, so silencing " +
"the countdown does not silence this.",
checked = config.alarmOnComplete,
onChange = { value -> onUpdate { it.copy(alarmOnComplete = value) } },
)
HorizontalRule()
Row(
Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = "Live Update",
style = KadenceTheme.type.body,
color = colors.ink,
)
Text(
text = if (Notifications.supportsPromotedOngoing) {
"The running interval appears as a status-bar chip and a " +
"progress row on the lock screen."
} else {
"This Android version shows an ordinary ongoing " +
"notification instead of a promoted one."
},
style = KadenceTheme.type.caption,
color = colors.inkMuted,
)
}
}
}
SectionTitle("Glyph Matrix")
Panel(padding = 0.dp) {
ToggleRow(
label = "Draw the timer on the rear matrix",
detail = if (BuildConfig.HAS_GLYPH_SDK) {
"Minutes and seconds inside a draining ring while an interval " +
"runs; today's pomodoros against the goal when nothing does."
} else {
"This build has no Glyph SDK, so the rear display does nothing. " +
"Run ./fetch-glyph-sdk.sh and rebuild."
},
checked = config.glyphEnabled && BuildConfig.HAS_GLYPH_SDK,
enabled = BuildConfig.HAS_GLYPH_SDK,
onChange = { value -> onUpdate { it.copy(glyphEnabled = value) } },
)
HorizontalRule()
Column(Modifier.padding(14.dp)) {
Text(
text = "Kadence is also a Glyph Toy",
style = KadenceTheme.type.body,
color = colors.ink,
)
Text(
text = "Settings → Glyph Interface → Glyph Toys to put it in the " +
"Glyph button's rotation. A long press there starts, pauses or " +
"resumes the interval.",
style = KadenceTheme.type.caption,
color = colors.inkMuted,
)
}
}
SectionTitle("Appearance")
Panel(padding = 0.dp) {
ToggleRow(
label = "Accent from the system palette",
detail = "The paper, the ink and the hairlines stay Kadence's; only the " +
"single accent follows your wallpaper. Chart colours never do — " +
"they have to stay readable.",
checked = config.dynamicAccent,
onChange = { value -> onUpdate { it.copy(dynamicAccent = value) } },
)
}
Text(
text = "Kadence ${BuildConfig.VERSION_NAME} · " +
if (BuildConfig.HAS_GLYPH_SDK) "with Glyph SDK" else "no Glyph SDK",
style = KadenceTheme.type.caption,
color = colors.inkFaint,
modifier = Modifier.padding(top = 4.dp),
)
}
}
@Composable
private fun ToggleRow(
label: String,
detail: String,
checked: Boolean,
onChange: (Boolean) -> Unit,
enabled: Boolean = true,
) {
val colors = KadenceTheme.colors
Row(
Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(Modifier.weight(1f)) {
Text(
text = label,
style = KadenceTheme.type.body,
color = if (enabled) colors.ink else colors.inkFaint,
)
Text(text = detail, style = KadenceTheme.type.caption, color = colors.inkMuted)
}
Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
}
}
/** Deep-link straight to Kadence's notification page, not the global one. */
private fun openAppNotificationSettings(context: Context) {
val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(AndroidSettings.EXTRA_APP_PACKAGE, context.packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
}
@@ -0,0 +1,299 @@
/**
* Every todo you own, grouped the way the other clients group them.
*
* Read-only, and that is the product decision rather than a shortcut: **you cannot tick
* a task off here.** Completion belongs to the tool the task lives in — close it in
* Jira, Todoist or Google Tasks and the next refresh notices. Two places to mark
* something done is two places to be wrong.
*
* So a row has exactly two actions and they follow from that. Tapping it **opens the
* task where it lives**, which is where closing it happens; "Track" attaches it to the
* running interval, which is the one thing that belongs to Kadence. A task with no
* `url` — a local one, or a source that supplied none — is simply not tappable rather
* than tappable-and-inert.
*
* Bucket order and labels come from `:core`'s `BUCKET_ORDER` / `BUCKET_LABEL`, so the
* phone cannot drift from the web app's wording — including the deliberate choice of
* "Would be nice to start" over the desktop's "In progress".
*/
package tech.kadence.android.ui.tasks
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.dto.TaskDto
import tech.kadence.android.data.net.ApiResult
import tech.kadence.android.ui.Loadable
import tech.kadence.android.ui.components.Banner
import tech.kadence.android.ui.components.Eyebrow
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.SectionTitle
import tech.kadence.android.ui.rememberLoad
import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.BUCKET_LABEL
import tech.kadence.core.BUCKET_ORDER
import tech.kadence.core.dueLabel
@Composable
fun TasksScreen(
repo: KadenceRepository,
runningTaskTitle: String?,
/** An interval is running — with or without a task attached to it. */
intervalRunning: Boolean,
onAttach: (Long) -> Unit,
) {
val colors = KadenceTheme.colors
val context = LocalContext.current
val scope = rememberCoroutineScope()
val loader = rememberLoad<tech.kadence.android.data.dto.TaskListDto> { repo.tasks() }
var refreshing by remember { mutableStateOf(false) }
var refreshNote by remember { mutableStateOf<String?>(null) }
fun refresh() {
refreshing = true
refreshNote = null
scope.launch {
// Deliberately the slow endpoint: it re-pulls every source. The cheap
// `/api/tasks` read is what the screen opens with; this is the explicit
// "go and ask Jira again" the user asked for by tapping.
when (val result = repo.refreshTasks()) {
is ApiResult.Ok -> {
val failed = result.value.sources.filter { !it.ok }
refreshNote = if (failed.isEmpty()) {
null
} else {
failed.joinToString("; ") { "${it.label}: ${it.error ?: "failed"}" }
}
loader.reload()
}
is ApiResult.NetworkError -> refreshNote = "No connection to the backend."
is ApiResult.HttpError -> refreshNote = result.detail
is ApiResult.Unauthorized -> refreshNote = "Not signed in."
}
refreshing = false
}
}
val state = loader.value
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 20.dp,
end = 20.dp,
top = 24.dp,
bottom = 32.dp,
),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
val total = (state as? Loadable.Loaded)?.value?.tasks?.size
PageHeader(
eyebrow = "Everything open",
title = "Tasks",
caption = total?.let { "$it across your sources" },
) {
GhostButton(
text = if (refreshing) "Refreshing…" else "Refresh sources",
onClick = ::refresh,
enabled = !refreshing,
)
}
}
refreshNote?.let { note ->
item { Banner(text = note, actionLabel = "Retry", onAction = ::refresh) }
}
when (state) {
Loadable.Idle, Loadable.Loading -> item {
Row(
Modifier.fillMaxWidth().padding(top = 40.dp),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(strokeWidth = 2.dp, color = colors.accent)
}
}
is Loadable.Failed -> item {
Banner(text = state.detail, actionLabel = "Retry", onAction = loader::reload)
}
is Loadable.Loaded -> {
state.staleError?.let { stale ->
item { Banner(text = stale, actionLabel = "Retry", onAction = loader::reload) }
}
val byBucket = state.value.tasks.groupBy { it.bucket }
// BUCKET_ORDER first, then anything the backend invented later — an
// unknown bucket must still be visible rather than silently dropped.
val buckets = BUCKET_ORDER.filter { byBucket.containsKey(it) } +
byBucket.keys.filterNot { it in BUCKET_ORDER }.sorted()
if (state.value.tasks.isEmpty()) {
item {
Panel {
Text(
text = "Nothing open. Either you are done, or no source " +
"is configured yet — the backend's Settings page " +
"is where they go.",
style = KadenceTheme.type.bodySmall,
color = colors.inkMuted,
)
}
}
}
buckets.forEach { bucket ->
val tasks = byBucket[bucket].orEmpty()
item(key = "head-$bucket") {
SectionTitle(
label = BUCKET_LABEL[bucket] ?: bucket,
trailing = "${tasks.size}",
)
}
item(key = "body-$bucket") {
Panel(padding = 0.dp) {
tasks.forEachIndexed { index, task ->
if (index > 0) HorizontalRule()
TaskRow(
task = task,
isRunning = task.title == runningTaskTitle,
anyIntervalRunning = intervalRunning,
// Tapping the row opens the task where it lives —
// Jira, Todoist, Google Tasks. That is where it
// gets *closed*, and this app deliberately cannot
// close it, so sending you there is the whole of
// what a tap should do.
onOpen = { openTask(context, task) },
// Attaching is the separate, explicit action.
onTrack = { onAttach(task.id) },
)
}
}
}
}
}
}
}
}
@Composable
private fun TaskRow(
task: TaskDto,
isRunning: Boolean,
anyIntervalRunning: Boolean,
onOpen: () -> Unit,
onTrack: () -> Unit,
) {
val colors = KadenceTheme.colors
val due = dueLabel(task.dueDate, task.daysOverdue)
val linkable = !task.url.isNullOrBlank()
Row(
Modifier
.fillMaxWidth()
// A local task has no URL and nothing to open; making it look tappable
// and then doing nothing is worse than it being plainly inert.
.then(if (linkable) Modifier.clickable(onClick = onOpen) else Modifier)
.padding(start = 14.dp, end = 4.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(Modifier.weight(1f)) {
Text(
text = task.title,
style = KadenceTheme.type.body,
color = if (task.stale) colors.inkMuted else colors.ink,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Eyebrow(task.sourceLabel.ifBlank { task.source })
if (due.isNotBlank()) {
Text(
text = due,
style = KadenceTheme.type.caption,
// Overdue is the one case worth colouring; everything else is
// a date and a date is not an alarm.
color = if (task.daysOverdue > 0) colors.clay else colors.inkFaint,
)
}
task.project?.takeIf { it.isNotBlank() }?.let { project ->
Text(
text = project,
style = KadenceTheme.type.caption,
color = colors.inkFaint,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
if (isRunning) {
Text(
text = "running",
style = KadenceTheme.type.caption,
color = colors.accent,
modifier = Modifier.padding(end = 10.dp),
)
} else {
TextButton(onClick = onTrack, contentPadding = PaddingValues(horizontal = 10.dp)) {
// "Focus" when the tap will begin an interval on this task, "Track"
// when it only re-points the one already running.
Text(
text = if (anyIntervalRunning) "Track" else "Focus",
style = KadenceTheme.type.button,
color = colors.accent,
)
}
}
}
}
/**
* Open the task in whatever app or browser owns it.
*
* `url` is absent for local tasks, and for a source that did not supply one; the row
* is not clickable in that case, so this is only ever reached with something to open.
* Still guarded, because an unparseable URL from a third-party API must not crash the
* list.
*/
private fun openTask(context: android.content.Context, task: TaskDto) {
val url = task.url?.takeIf { it.isNotBlank() } ?: return
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
.onFailure { android.util.Log.w("Kadence", "cannot open $url: ${it.message}") }
}
@@ -0,0 +1,385 @@
/**
* The timer. The screen this app exists for.
*
* It renders [TimerUi] and does nothing else — no polling, no ticking, no arithmetic.
* The engine on the Application is the single source, so this screen, the Live Update
* and the rear matrix are three views of one number.
*
* Two deliberate restraints:
*
* * **The dial reads `remaining` through a lambda.** The engine emits every second;
* passing the value would recompose the whole page 60 times a minute, and passing
* the lambda keeps the tick in the draw phase. The dial was written for this.
* * **One primary button.** Start, or pause/resume. Everything else is a ghost
* button, which is the house rule from the web app — and on a phone it also means
* the thing you reach for while working is the one with a filled background.
*/
package tech.kadence.android.ui.timer
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.FlowRow
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.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import tech.kadence.android.data.dto.PomodoroPresetDto
import tech.kadence.android.notify.Notifications
import tech.kadence.android.timer.Connection
import tech.kadence.android.timer.TimerUi
import tech.kadence.android.ui.components.Banner
import tech.kadence.android.ui.components.DotRow
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_LABEL
import tech.kadence.core.Kind
import tech.kadence.core.abandonNeedsReason
import tech.kadence.core.humanMinutes
import java.time.LocalDate
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
@Composable
fun TimerScreen(
ui: TimerUi,
presets: List<PomodoroPresetDto>,
activePresetName: String?,
onApplyPreset: (PomodoroPresetDto) -> Unit,
onStart: (minutes: Int?) -> Unit,
onToggle: () -> Unit,
onComplete: () -> Unit,
onAbandon: (reason: String?) -> Unit,
onExtend: () -> Unit,
onInterrupt: () -> Unit,
onDismissNotice: () -> Unit,
onOpenSettings: () -> Unit,
) {
val colors = KadenceTheme.colors
val context = LocalContext.current
var askingReason by remember { mutableStateOf(false) }
var reason by remember { mutableStateOf("") }
var preset by remember { mutableStateOf<Int?>(null) }
// Without POST_NOTIFICATIONS the Live Update never appears and the foreground
// service has nothing to show — on Android 13+ that is the single most likely
// reason for "notifications are not working", so it is asked for here rather
// than buried in Settings.
var notificationsAllowed by remember { mutableStateOf(Notifications.permitted(context)) }
val askNotifications = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> notificationsAllowed = granted }
LaunchedEffect(Unit) {
notificationsAllowed = Notifications.permitted(context)
}
val label = KIND_LABEL[ui.kind] ?: ui.kind
val today = LocalDate.now()
val eyebrow = "${today.dayOfWeek.getDisplayName(JavaTextStyle.FULL, Locale.getDefault())}, " +
"${today.dayOfMonth} ${today.month.getDisplayName(JavaTextStyle.FULL, Locale.getDefault())}"
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
PageHeader(
eyebrow = eyebrow,
title = if (ui.isActive) label else "Ready",
caption = ui.taskTitle?.let { "Tracking “$it”" },
) {
if (ui.isActive) {
GhostButton(
text = if (ui.isPaused) "Resume" else "Pause",
onClick = onToggle,
enabled = !ui.busy,
)
PrimaryButton(text = "Finish", onClick = onComplete, enabled = !ui.busy)
} else {
PrimaryButton(
text = "Start ${(KIND_LABEL[ui.nextKind] ?: ui.nextKind).lowercase()}",
onClick = { onStart(preset) },
enabled = !ui.busy && ui.timerEnabled,
)
}
}
ConnectionBanner(ui, onOpenSettings)
if (!notificationsAllowed && Build.VERSION.SDK_INT >= 33) {
Banner(
text = "Notifications are off, so there is no countdown in the status " +
"bar and no alarm when an interval ends.",
actionLabel = "Allow",
onAction = { askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) },
)
}
if (!ui.timerEnabled) {
Banner(text = "The timer is switched off in the backend's settings.")
}
ui.notice?.let { notice ->
Banner(text = notice, actionLabel = "Dismiss", onAction = onDismissNotice)
}
// Idle, the dial previews what pressing Start would give you: a full ring at
// the configured length. Showing `remaining` (zero) instead would read as a
// finished interval, which is the opposite of "ready".
val nextPlanned = (preset ?: ui.durationsMinutes[ui.nextKind] ?: 25) * 60
val dialPlanned = if (ui.isActive) ui.plannedSeconds else nextPlanned
Panel(padding = 22.dp) {
TimerDial(
label = label,
// A lambda, not a value — see the file header.
remaining = { if (ui.isActive) ui.remaining else nextPlanned },
plannedSeconds = dialPlanned.coerceAtLeast(1),
accent = when (ui.kind) {
Kind.WORK -> KadenceTheme.viz.work
Kind.SHORT_BREAK -> KadenceTheme.viz.shortBreak
else -> KadenceTheme.viz.longBreak
},
sublabel = if (ui.isActive && ui.isWork) {
"Cycle ${ui.cyclePosition} of ${ui.cyclesBeforeLongBreak} before a long break"
} else {
null
},
paused = ui.isPaused,
idle = !ui.isActive,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
if (ui.isActive) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
GhostButton(text = "+5 min", onClick = onExtend, enabled = !ui.busy)
if (ui.isWork) {
GhostButton(
text = "Interrupted",
onClick = onInterrupt,
enabled = !ui.busy,
)
}
GhostButton(
text = "Give up",
onClick = {
// Strict mode refuses a bare abandon on a work interval, so
// the reason is collected here rather than sending a call
// that is certain to come back 400.
if (abandonNeedsReason(true, ui.kind, null)) {
reason = ""
askingReason = true
} else {
onAbandon(null)
}
},
enabled = !ui.busy,
)
}
} else {
// The server holds the real durations; these offer the configured
// length plus two common overrides, not a parallel set of settings.
val configured = ui.durationsMinutes[ui.nextKind]
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
PresetChip(
text = configured?.let { "$it min" } ?: "Default",
selected = preset == null,
onClick = { preset = null },
)
listOf(15, 50).forEach { minutes ->
PresetChip(
text = "$minutes min",
selected = preset == minutes,
onClick = { preset = minutes },
)
}
}
}
}
if (presets.isNotEmpty()) {
SectionTitle("Rhythm", trailing = activePresetName ?: "custom")
Panel {
Text(
text = "A rhythm sets the interval lengths for every client at " +
"once — it writes to the server's settings, not just this phone.",
style = KadenceTheme.type.caption,
color = colors.inkMuted,
)
Spacer(Modifier.height(12.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
presets.forEach { preset ->
PresetChip(
text = preset.name,
selected = preset.name == activePresetName,
// Changing the rhythm mid-interval would leave the running
// one on the old length and the dial mid-redraw; the server
// applies lengths at start, so this waits for idle.
onClick = { if (!ui.isActive) onApplyPreset(preset) },
)
}
}
Spacer(Modifier.height(8.dp))
Text(
text = presets.firstOrNull { it.name == activePresetName }?.let {
"${it.workMinutes} / ${it.shortBreakMinutes} / " +
"${it.longBreakMinutes} min · long break every " +
"${it.cyclesBeforeLongBreak}"
} ?: "Lengths do not match any saved rhythm.",
style = KadenceTheme.type.caption,
color = colors.inkFaint,
)
if (ui.isActive) {
Spacer(Modifier.height(6.dp))
Text(
text = "Finish the current interval to change rhythm.",
style = KadenceTheme.type.caption,
color = colors.inkFaint,
)
}
}
}
SectionTitle("Today", trailing = "${ui.completedToday} of ${ui.goal}")
Panel(padding = 0.dp) {
Column(Modifier.padding(16.dp)) {
DotRow(done = ui.completedToday, goal = ui.goal)
}
HorizontalRule()
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Stat(
label = "Pomodoros",
value = "${ui.completedToday}",
caption = "goal ${ui.goal}",
size = StatSize.Large,
accent = colors.accent,
tabular = true,
modifier = Modifier.weight(1f).padding(16.dp),
)
VerticalRule(height = 84.dp)
Stat(
label = if (ui.isActive) "This interval" else "Next up",
value = if (ui.isActive) {
humanMinutes(ui.plannedSeconds / 60)
} else {
humanMinutes(ui.durationsMinutes[ui.nextKind] ?: 0)
},
caption = if (ui.isActive && ui.interruptions > 0) {
"${ui.interruptions} interruption${if (ui.interruptions == 1) "" else "s"}"
} else {
KIND_LABEL[if (ui.isActive) ui.kind else ui.nextKind]?.lowercase()
},
size = StatSize.Large,
tabular = true,
modifier = Modifier.weight(1f).padding(16.dp),
)
}
}
}
if (askingReason) {
AlertDialog(
onDismissRequest = { askingReason = false },
title = { Text("Why stop?", style = KadenceTheme.type.pageTitle) },
text = {
Column {
Text(
text = "Strict mode records a reason when a focus interval is " +
"abandoned. It goes in the evening report.",
style = KadenceTheme.type.bodySmall,
color = colors.inkMuted,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = reason,
onValueChange = { reason = it },
singleLine = false,
textStyle = KadenceTheme.type.body,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
onClick = {
askingReason = false
onAbandon(reason.ifBlank { null })
},
) { Text("Abandon", style = KadenceTheme.type.button, color = colors.clay) }
},
dismissButton = {
TextButton(onClick = { askingReason = false }) {
Text("Keep going", style = KadenceTheme.type.button)
}
},
containerColor = colors.surface,
)
}
}
@Composable
private fun ConnectionBanner(ui: TimerUi, onOpenSettings: () -> Unit) {
when (ui.connection) {
Connection.Offline -> Banner(
text = "No connection to the backend. The countdown keeps running from " +
"where the server last left it.",
)
Connection.Unauthorized -> Banner(
text = "The backend rejected our credentials.",
actionLabel = "Settings",
onAction = onOpenSettings,
)
Connection.Unconfigured -> Banner(
text = "No backend configured yet.",
actionLabel = "Set up",
onAction = onOpenSettings,
)
Connection.Connecting, Connection.Online -> Unit
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The preview Nothing's Glyph Toys settings list shows beside "Kadence".
Drawn as the dot matrix the toy actually renders: a draining ring of ticks with
the remaining minutes inside it, in the 3x5 dot font from `:core`'s GlyphFrame.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp"
android:height="96dp"
android:viewportWidth="25"
android:viewportHeight="25">
<group android:translateX="0" android:translateY="0">
<!-- Ring: 12 of 20 ticks lit, so the preview shows a partly-drained dial. -->
<path android:fillColor="#FFFFFF" android:pathData="M12,0.5 h1 v1 h-1 z M15,0.8 h1 v1 h-1 z M18,2 h1 v1 h-1 z M20.5,4 h1 v1 h-1 z M22.3,6.8 h1 v1 h-1 z M23.2,10 h1 v1 h-1 z M23.2,13.5 h1 v1 h-1 z M22.3,16.7 h1 v1 h-1 z M20.5,19.5 h1 v1 h-1 z M18,21.5 h1 v1 h-1 z M15,22.7 h1 v1 h-1 z M12,23 h1 v1 h-1 z" />
<path android:fillColor="#FFFFFF" android:fillAlpha="0.25" android:pathData="M8.8,22.7 h1 v1 h-1 z M5.8,21.5 h1 v1 h-1 z M3.3,19.5 h1 v1 h-1 z M1.5,16.7 h1 v1 h-1 z M0.6,13.5 h1 v1 h-1 z M0.6,10 h1 v1 h-1 z M1.5,6.8 h1 v1 h-1 z M3.3,4 h1 v1 h-1 z M5.8,2 h1 v1 h-1 z M8.8,0.8 h1 v1 h-1 z" />
<!-- "18" over "24", the 3x5 dot font, centred. -->
<path android:fillColor="#FFFFFF" android:pathData="M12,6 h1 v1 h-1 z M12,7 h1 v1 h-1 z M12,8 h1 v1 h-1 z M12,9 h1 v1 h-1 z M12,10 h1 v1 h-1 z M14,6 h1 v1 h-1 z M15,6 h1 v1 h-1 z M16,6 h1 v1 h-1 z M14,7 h1 v1 h-1 z M16,7 h1 v1 h-1 z M14,8 h1 v1 h-1 z M15,8 h1 v1 h-1 z M16,8 h1 v1 h-1 z M14,9 h1 v1 h-1 z M16,9 h1 v1 h-1 z M14,10 h1 v1 h-1 z M15,10 h1 v1 h-1 z M16,10 h1 v1 h-1 z" />
<path android:fillColor="#FFFFFF" android:pathData="M8,14 h1 v1 h-1 z M10,14 h1 v1 h-1 z M8,15 h1 v1 h-1 z M10,15 h1 v1 h-1 z M8,16 h1 v1 h-1 z M9,16 h1 v1 h-1 z M10,16 h1 v1 h-1 z M10,17 h1 v1 h-1 z M10,18 h1 v1 h-1 z M12,14 h1 v1 h-1 z M13,14 h1 v1 h-1 z M14,14 h1 v1 h-1 z M14,15 h1 v1 h-1 z M12,16 h1 v1 h-1 z M13,16 h1 v1 h-1 z M14,16 h1 v1 h-1 z M12,17 h1 v1 h-1 z M12,18 h1 v1 h-1 z M13,18 h1 v1 h-1 z M14,18 h1 v1 h-1 z" />
</group>
</vector>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The status-bar icon. A notification icon is a mask: Android keeps the alpha and
throws the colour away, so this is one flat white shape and nothing else — the
tomato's red and the leaf's green would both come out as the same grey block.
The ring drains clockwise from twelve, matching the dial in every other client.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<!-- The dial track, as a ring. -->
<path
android:pathData="M12,3 A9,9 0 1,1 11.99,3 Z M12,5.6 A6.4,6.4 0 1,0 12.01,5.6 Z"
android:fillColor="#FFFFFF"
android:fillAlpha="0.45"
android:fillType="evenOdd" />
<!-- The spent arc: a quarter turn from the top, thicker so it reads at 24dp. -->
<path
android:pathData="M12,2.1 A9.9,9.9 0 0,1 21.9,12 L19.2,12 A7.2,7.2 0 0,0 12,4.8 Z"
android:fillColor="#FFFFFF" />
<!-- The hand. -->
<path
android:pathData="M11.1,7.2 L12.9,7.2 L12.9,12.9 L11.1,12.9 Z"
android:fillColor="#FFFFFF" />
</vector>
@@ -1,4 +1,30 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Kadence</string> <string name="app_name">Kadence</string>
<!-- Notification channels. Two, because they want opposite settings: the
running timer must never make a sound, and the interval's end must. -->
<string name="channel_timer_name">Running interval</string>
<string name="channel_timer_description">
The live countdown while a focus interval or break is running. Silent.
</string>
<string name="channel_alarm_name">Interval finished</string>
<string name="channel_alarm_description">
Rings when a focus interval or break reaches zero.
</string>
<!-- Notification actions. -->
<string name="action_pause">Pause</string>
<string name="action_resume">Resume</string>
<string name="action_finish">Finish</string>
<string name="action_extend">+5 min</string>
<string name="action_start_break">Start break</string>
<string name="action_start_focus">Start focus</string>
<!-- The Glyph Toy, as Nothing's Glyph Toys settings list shows it. -->
<string name="toy_name">Kadence</string>
<string name="toy_summary">
The running interval on the rear matrix: minutes and seconds inside a
draining ring, or today\'s pomodoros against your goal when nothing is running.
</string>
</resources> </resources>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Cleartext is permitted, and that is a deliberate decision rather than an oversight.
Kadence talks to a backend the *user* hosts and the user names. The documented
deployment is HTTPS behind a reverse proxy (kadence.timka.su), and
`KadencePrefs.normalizeBaseUrl` guesses `https://` for anything that looks like a
hostname — but the equally supported case is `make up` on a laptop and the phone
pointed at `http://192.168.1.x:8000` over the home LAN, which no certificate will
ever cover. Refusing that would mean the only way to try the app is to first stand
up TLS.
The session cookie is marked `Secure` by the server whenever the request arrived as
HTTPS (`routes_auth._is_https`), so a cleartext LAN session cannot leak a cookie
minted over TLS; and the X-API-Token the phone sends is the same secret the desktop
app sends over the same LAN.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<!-- So a self-hosted backend behind a corporate or home CA works without
a rebuild. User-added anchors are trusted only because the user is
the operator of the server this app talks to. -->
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,20 @@
/**
* The rear display when Nothing's SDK is absent.
*
* Compiled instead of `src/glyph/kotlin` whenever `app/libs/glyph-matrix-sdk-2.0.aar`
* is missing — see `app/build.gradle.kts`. The .aar's licence forbids redistribution,
* so a fresh clone has no copy of it and lands here until `./fetch-glyph-sdk.sh` runs.
*
* The point of this file is that *nothing else changes*. The timer, the notification
* and the Live Update are unaffected; the matrix simply never lights. That is also the
* correct behaviour on a phone that has no Glyph Matrix at all, which is every phone
* but a Nothing — so this is not only a build fallback, it is a real runtime case.
*/
package tech.kadence.android.glyph
import android.content.Context
import tech.kadence.core.GlyphFrame
internal fun newGlyphDisplay(context: Context): GlyphDisplay = NoopGlyphDisplay()
internal fun glyphMatrixSize(): Int = GlyphFrame.DEFAULT_SIZE
@@ -0,0 +1,53 @@
/**
* What someone can type into the backend field, and what it has to become.
*
* `normalizeBaseUrl` is the one piece of input handling between a phone keyboard and
* okhttp, and getting it wrong is indistinguishable from the backend being down. The
* interesting rule is the scheme guess: a hostname gets HTTPS, but a LAN address gets
* HTTP, because no certificate will ever cover `192.168.1.40` and defaulting it to
* HTTPS turns "make up on my laptop and point the phone at it" into a TLS error.
*/
package tech.kadence.android
import org.junit.Test
import kotlin.test.assertEquals
import tech.kadence.android.data.store.KadencePrefs.Companion.normalizeBaseUrl
class NormalizeBaseUrlTest {
@Test
fun `an explicit scheme is left alone`() {
assertEquals("https://kadence.timka.su", normalizeBaseUrl("https://kadence.timka.su"))
assertEquals("http://192.168.1.40:8000", normalizeBaseUrl("http://192.168.1.40:8000"))
}
@Test
fun `a bare hostname gets https`() {
assertEquals("https://kadence.timka.su", normalizeBaseUrl("kadence.timka.su"))
}
@Test
fun `trailing slashes and spaces go`() {
assertEquals("https://kadence.timka.su", normalizeBaseUrl(" kadence.timka.su/ "))
assertEquals("https://kadence.timka.su", normalizeBaseUrl("https://kadence.timka.su///"))
}
@Test
fun `a LAN address gets http, because no certificate covers it`() {
assertEquals("http://192.168.1.40:8000", normalizeBaseUrl("192.168.1.40:8000"))
assertEquals("http://10.0.2.2:8000", normalizeBaseUrl("10.0.2.2:8000"))
assertEquals("http://localhost:8000", normalizeBaseUrl("localhost:8000"))
assertEquals("http://kadence.local", normalizeBaseUrl("kadence.local"))
}
@Test
fun `a path is preserved, because the backend may sit under one`() {
assertEquals("https://example.com/kadence", normalizeBaseUrl("example.com/kadence"))
}
@Test
fun `empty stays empty rather than becoming a scheme`() {
assertEquals("", normalizeBaseUrl(""))
assertEquals("", normalizeBaseUrl(" "))
}
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Fetch Nothing's Glyph Matrix SDK into app/libs/.
#
# The .aar is not on Maven and its licence (clause 2.1(c)) forbids
# redistribution, so it cannot be committed — hence this script and the
# .gitignore entry. Without it the build still works: `app/build.gradle.kts`
# compiles the no-op display instead and the rear matrix is simply inert.
#
# ./fetch-glyph-sdk.sh # or: make glyph-sdk
set -euo pipefail
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
DEST="$HERE/app/libs/glyph-matrix-sdk-2.0.aar"
KIT=https://github.com/Nothing-Developer-Programme/GlyphMatrix-Developer-Kit
RAW="$KIT/raw/main/glyph-matrix-sdk-2.0.aar"
if [[ -f $DEST && ${1:-} != --force ]]; then
echo "already present: $DEST"
echo "(pass --force to re-download)"
exit 0
fi
mkdir -p "$(dirname "$DEST")"
echo "downloading the Glyph Matrix SDK from $KIT"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
curl -fsSL -o "$tmp" "$RAW"
# An .aar is a zip holding classes.jar; a redirect to an HTML error page is not.
if ! unzip -l "$tmp" 2>/dev/null | grep -q classes.jar; then
echo "error: what came back is not an .aar. Download it by hand from" >&2
echo " $KIT" >&2
echo "and put it at $DEST" >&2
exit 1
fi
mv "$tmp" "$DEST"
trap - EXIT
echo "installed $DEST ($(du -h "$DEST" | cut -f1))"
echo
echo "Licence: Nothing's Glyph SDK EULA — see $KIT/blob/main/LICENSE.md."
echo "It is not redistributable, which is why this file is git-ignored."
+8
View File
@@ -17,6 +17,10 @@ robolectric = "4.14.1"
serialization = "1.11.0" serialization = "1.11.0"
okhttp = "5.5.0" okhttp = "5.5.0"
junit = "4.13.2" junit = "4.13.2"
# 1.19.1 is what carries NotificationCompat.ProgressStyle and
# setRequestPromotedOngoing — the Android 16 Live Update API, via the compat layer
# rather than raw platform calls behind SDK_INT checks.
androidxCore = "1.19.1"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -41,6 +45,10 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref
navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins] [plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }