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:
32 files changed
+4118
-162
No files matched your search
@@ -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.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+27
@@ -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
|
||||||
@@ -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(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+42
@@ -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."
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in new issue
Block a user