From a7148b69c603e630b0fe516c8714843deecd00be Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Mon, 19 Oct 2026 16:05:13 +0300 Subject: [PATCH] =?UTF-8?q?android:=20the=20real=20client=20=E2=80=94=20ba?= =?UTF-8?q?ckend,=20notifications,=20Live=20Update,=20Glyph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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 --- android/app/build.gradle.kts | 87 +++- android/app/proguard-rules.pro | 27 ++ .../android/glyph/MatrixGlyphDisplay.kt | 162 +++++++ android/app/src/main/AndroidManifest.xml | 83 +++- .../kadence/android/KadenceApplication.kt | 111 ++++- .../tech/kadence/android/MainActivity.kt | 187 ++------ .../kadence/android/data/KadenceRepository.kt | 182 ++++++++ .../tech/kadence/android/data/dto/Dtos.kt | 43 ++ .../kadence/android/data/net/KadenceApi.kt | 9 + .../android/data/store/KadencePrefs.kt | 105 +++++ .../kadence/android/glyph/GlyphDisplay.kt | 80 ++++ .../kadence/android/glyph/GlyphPresenter.kt | 87 ++++ .../kadence/android/glyph/KadenceGlyphToy.kt | 137 ++++++ .../kadence/android/notify/Notifications.kt | 287 ++++++++++++ .../kadence/android/service/TimerService.kt | 200 ++++++++ .../tech/kadence/android/timer/TimerEngine.kt | 427 ++++++++++++++++++ .../tech/kadence/android/ui/KadenceApp.kt | 216 +++++++++ .../tech/kadence/android/ui/Loadable.kt | 65 +++ .../android/ui/components/Primitives.kt | 88 ++++ .../android/ui/onboarding/OnboardingScreen.kt | 262 +++++++++++ .../kadence/android/ui/report/ReportScreen.kt | 240 ++++++++++ .../android/ui/settings/SettingsScreen.kt | 280 ++++++++++++ .../kadence/android/ui/tasks/TasksScreen.kt | 299 ++++++++++++ .../kadence/android/ui/timer/TimerScreen.kt | 385 ++++++++++++++++ .../res/drawable/ic_glyph_toy_preview.xml | 22 + .../src/main/res/drawable/ic_notification.xml | 32 ++ android/app/src/main/res/values/strings.xml | 26 ++ .../main/res/xml/network_security_config.xml | 28 ++ .../kadence/android/glyph/NoGlyphVariant.kt | 20 + .../kotlin/tech/kadence/android/PrefsTest.kt | 53 +++ android/fetch-glyph-sdk.sh | 42 ++ android/gradle/libs.versions.toml | 8 + 32 files changed, 4118 insertions(+), 162 deletions(-) create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/glyph/kotlin/tech/kadence/android/glyph/MatrixGlyphDisplay.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/data/KadenceRepository.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/data/store/KadencePrefs.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/glyph/GlyphDisplay.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/glyph/GlyphPresenter.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/glyph/KadenceGlyphToy.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/notify/Notifications.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/service/TimerService.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/timer/TimerEngine.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/ui/Loadable.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/ui/onboarding/OnboardingScreen.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/ui/report/ReportScreen.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/ui/settings/SettingsScreen.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TasksScreen.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/ui/timer/TimerScreen.kt create mode 100644 android/app/src/main/res/drawable/ic_glyph_toy_preview.xml create mode 100644 android/app/src/main/res/drawable/ic_notification.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 android/app/src/noglyph/kotlin/tech/kadence/android/glyph/NoGlyphVariant.kt create mode 100644 android/app/src/test/kotlin/tech/kadence/android/PrefsTest.kt create mode 100755 android/fetch-glyph-sdk.sh diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5f120b0..8bc7cf0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,6 +6,19 @@ plugins { 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 { namespace = "tech.kadence.android" compileSdk = libs.versions.compileSdk.get().toInt() @@ -15,8 +28,43 @@ android { minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 1 - versionName = "0.1.0" + versionName = "1.0.0" 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 { @@ -25,7 +73,17 @@ android { applicationIdSuffix = ".debug" } 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 { 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 { @@ -54,6 +119,8 @@ dependencies { implementation(libs.serialization.json) implementation(libs.okhttp) + implementation(libs.androidx.core) + implementation(libs.coroutines.android) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) @@ -67,6 +134,11 @@ dependencies { implementation(libs.navigation.compose) implementation(libs.lifecycle.viewmodel.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 // device or an emulator — the same reason the logic lives in :core. @@ -83,3 +155,14 @@ dependencies { androidTestImplementation(libs.compose.ui.test.junit4) 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.", + ) + } +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..7fbcb1a --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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.** diff --git a/android/app/src/glyph/kotlin/tech/kadence/android/glyph/MatrixGlyphDisplay.kt b/android/app/src/glyph/kotlin/tech/kadence/android/glyph/MatrixGlyphDisplay.kt new file mode 100644 index 0000000..921ebec --- /dev/null +++ b/android/app/src/glyph/kotlin/tech/kadence/android/glyph/MatrixGlyphDisplay.kt @@ -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 . */ +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 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 " + + ".", + ) + 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 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1f01607..b6adef3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + + + + + + + + + + + + + + + + + + @@ -20,11 +52,60 @@ android:name=".MainActivity" android:exported="true" android:label="@string/app_name" + android:launchMode="singleTop" android:theme="@style/Theme.Kadence"> + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt b/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt index 5f53d9f..40ace93 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt @@ -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 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() } + } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/MainActivity.kt b/android/app/src/main/kotlin/tech/kadence/android/MainActivity.kt index 14ace55..546d142 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/MainActivity.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/MainActivity.kt @@ -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 import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.delay -import tech.kadence.android.ui.components.GhostButton -import tech.kadence.android.ui.components.HorizontalRule -import tech.kadence.android.ui.components.PageHeader -import tech.kadence.android.ui.components.Panel -import tech.kadence.android.ui.components.PresetChip -import tech.kadence.android.ui.components.PrimaryButton -import tech.kadence.android.ui.components.SectionTitle -import tech.kadence.android.ui.components.Stat -import tech.kadence.android.ui.components.StatSize -import tech.kadence.android.ui.components.TimerDial -import tech.kadence.android.ui.components.VerticalRule +import kotlinx.coroutines.launch +import tech.kadence.android.ui.KadenceApp 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() { + + private val app: KadenceApplication get() = application as KadenceApplication + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) 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 { - KadenceTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = KadenceTheme.colors.paper, - ) { - DesignGallery() - } + val config by app.prefs.config.collectAsStateWithLifecycle() + KadenceTheme(dynamicAccent = config.dynamicAccent) { + KadenceApp(app) } } } } - -/** - * A scratch screen for the design system, standing in until the real Timer screen - * lands. It exists so the tokens, the type and the dial can be checked against the web - * app side by side on a device. - */ -@Composable -private fun DesignGallery() { - val colors = KadenceTheme.colors - val planned = 25 * 60 - - // A live countdown, so the dial and the fixed-advance clock can be watched ticking. - var remaining by remember { mutableIntStateOf(18 * 60 + 24) } - var paused by remember { mutableStateOf(false) } - var preset by remember { mutableIntStateOf(0) } - - LaunchedEffect(paused) { - while (!paused) { - delay(1000) - remaining = if (remaining > 0) remaining - 1 else planned - } - } - - Column( - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 28.dp), - verticalArrangement = Arrangement.spacedBy(22.dp), - ) { - PageHeader( - eyebrow = "Monday, 19 October", - title = KIND_LABEL.getValue(Kind.WORK), - caption = "Tracking \"Fix the flaky importer\"", - ) { - GhostButton(text = if (paused) "Resume" else "Pause", onClick = { paused = !paused }) - PrimaryButton(text = "Finish", onClick = { remaining = planned }) - } - - Panel(padding = 24.dp) { - TimerDial( - label = KIND_LABEL.getValue(Kind.WORK), - remaining = { remaining }, - plannedSeconds = planned, - accent = KadenceTheme.viz.work, - sublabel = "Cycle 2 of 4 before a long break", - paused = paused, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(14.dp)) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - ) { - listOf("Classic", "Пара", "Custom").forEachIndexed { index, name -> - PresetChip( - text = name, - selected = preset == index, - onClick = { preset = index }, - ) - } - } - } - - SectionTitle("Today", trailing = "6 of 8") - Panel(padding = 0.dp) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Stat( - label = "Focus today", - value = humanMinutes(155), - caption = "35m resting", - modifier = Modifier.weight(1f).padding(16.dp), - ) - VerticalRule(height = 70.dp) - Stat( - label = "Interruptions", - value = "3", - caption = "counted, not stopped", - tabular = true, - modifier = Modifier.weight(1f).padding(16.dp), - ) - } - HorizontalRule() - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Stat( - label = "Score", - value = "72", - caption = "out of 100", - size = StatSize.Large, - accent = colors.accent, - tabular = true, - modifier = Modifier.weight(1f).padding(16.dp), - ) - VerticalRule(height = 84.dp) - Stat( - label = "Streak", - value = "4d", - caption = "best 11", - size = StatSize.Large, - modifier = Modifier.weight(1f).padding(16.dp), - ) - } - } - - SectionTitle("Palette") - Panel { - Text( - text = "The accent is taken from the system palette; paper, ink and the " + - "hairlines stay Kadence's. Chart colours never follow the accent.", - style = KadenceTheme.type.bodySmall, - color = colors.inkMuted, - ) - } - } -} diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/KadenceRepository.kt b/android/app/src/main/kotlin/tech/kadence/android/data/KadenceRepository.kt new file mode 100644 index 0000000..52142fc --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/data/KadenceRepository.kt @@ -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 = api.get("/api/health") + + suspend fun authMe(): ApiResult = api.get("/api/auth/me") + + // ---- timer -------------------------------------------------------------- + + suspend fun timerState(): ApiResult = 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 = api.post( + "/api/timer/start", + body( + "kind" to kind, + "minutes" to minutes, + "task_id" to taskId, + "note" to note, + ), + ) + + suspend fun pause(): ApiResult = api.post("/api/timer/pause") + + suspend fun resume(): ApiResult = api.post("/api/timer/resume") + + suspend fun complete(autoNext: Boolean = true): ApiResult = + api.post("/api/timer/complete", body("auto_next" to autoNext)) + + suspend fun abandon(reason: String? = null): ApiResult = + api.post("/api/timer/abandon", body("reason" to reason)) + + suspend fun interrupt(): ApiResult = api.post("/api/timer/interrupt") + + suspend fun extend(minutes: Int = 5): ApiResult = + api.post("/api/timer/extend", body("minutes" to minutes)) + + suspend fun attachTask(taskId: Long?): ApiResult = + api.post("/api/timer/task", body("task_id" to taskId)) + + // ---- tasks -------------------------------------------------------------- + + suspend fun tasks(): ApiResult = api.get("/api/tasks") + + /** Fans out to Jira, Todoist, Google and the laundry site; slow by nature. */ + suspend fun refreshTasks(): ApiResult = api.post("/api/tasks/refresh") + + // ---- reports ------------------------------------------------------------ + + suspend fun dailyReport(): ApiResult = api.get("/api/reports/daily") + + suspend fun statsSummary(): ApiResult = api.get("/api/stats/summary") + + // ---- settings ----------------------------------------------------------- + + suspend fun settings(): ApiResult = 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): ApiResult { + 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 = + patchPomodoro(mapOf("timer_enabled" to enabled)) + + /** Apply one of the server's named rhythms. */ + suspend fun applyPreset(preset: PomodoroPresetDto): ApiResult = + 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 = api.get("/api/blocker/policy") + + suspend fun blockEvents(): ApiResult = 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 = 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 { + 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)) +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt b/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt index 89bd5cb..586b0ed 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt @@ -308,3 +308,46 @@ data class SessionsResponseDto( val sessions: List = emptyList(), 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 = 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(), +) diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/net/KadenceApi.kt b/android/app/src/main/kotlin/tech/kadence/android/data/net/KadenceApi.kt index b8c2ab2..083b5a4 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/data/net/KadenceApi.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/data/net/KadenceApi.kt @@ -140,6 +140,15 @@ class KadenceApi( 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()) } } catch (e: IOException) { diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/store/KadencePrefs.kt b/android/app/src/main/kotlin/tech/kadence/android/data/store/KadencePrefs.kt new file mode 100644 index 0000000..f31d5e6 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/data/store/KadencePrefs.kt @@ -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 = _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" + } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/glyph/GlyphDisplay.kt b/android/app/src/main/kotlin/tech/kadence/android/glyph/GlyphDisplay.kt new file mode 100644 index 0000000..2c00643 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/glyph/GlyphDisplay.kt @@ -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" diff --git a/android/app/src/main/kotlin/tech/kadence/android/glyph/GlyphPresenter.kt b/android/app/src/main/kotlin/tech/kadence/android/glyph/GlyphPresenter.kt new file mode 100644 index 0000000..728673a --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/glyph/GlyphPresenter.kt @@ -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) + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/glyph/KadenceGlyphToy.kt b/android/app/src/main/kotlin/tech/kadence/android/glyph/KadenceGlyphToy.kt new file mode 100644 index 0000000..1bb3b15 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/glyph/KadenceGlyphToy.kt @@ -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() +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/notify/Notifications.kt b/android/app/src/main/kotlin/tech/kadence/android/notify/Notifications.kt new file mode 100644 index 0000000..fe60d0a --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/notify/Notifications.kt @@ -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 +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/service/TimerService.kt b/android/app/src/main/kotlin/tech/kadence/android/service/TimerService.kt new file mode 100644 index 0000000..50b79d0 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/service/TimerService.kt @@ -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), + ) + } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/timer/TimerEngine.kt b/android/app/src/main/kotlin/tech/kadence/android/timer/TimerEngine.kt new file mode 100644 index 0000000..a09bf9e --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/timer/TimerEngine.kt @@ -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 = 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 = _state.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 8) + val events: SharedFlow = _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) = 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 diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt new file mode 100644 index 0000000..03491cb --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt @@ -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(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, + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/Loadable.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/Loadable.kt new file mode 100644 index 0000000..e7d929e --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/Loadable.kt @@ -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 { + data object Idle : Loadable + data object Loading : Loadable + data class Loaded(val value: T, val staleError: String? = null) : Loadable + data class Failed(val detail: String) : Loadable +} + +/** Drives a [Loadable] from a suspending call, with a `reload` to hand back. */ +class Loader( + private val state: MutableState>, + private val scope: kotlinx.coroutines.CoroutineScope, + private val call: suspend () -> ApiResult, +) { + val value: Loadable 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, detail: String): Loadable = + if (previous is Loadable.Loaded) previous.copy(staleError = detail) else Loadable.Failed(detail) +} + +@Composable +fun rememberLoad(vararg keys: Any?, call: suspend () -> ApiResult): Loader { + val state = remember { mutableStateOf>(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 +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/components/Primitives.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/components/Primitives.kt index 070167d..3c90a17 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/components/Primitives.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/components/Primitives.kt @@ -9,6 +9,7 @@ package tech.kadence.android.ui.components import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.height 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.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow 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) + }, + ), + ) + } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/onboarding/OnboardingScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..7aa76b3 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/onboarding/OnboardingScreen.kt @@ -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(null) } + var reassurance by remember { mutableStateOf(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) + } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/report/ReportScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/report/ReportScreen.kt new file mode 100644 index 0000000..faec27e --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/report/ReportScreen.kt @@ -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 { 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, + ) + } + } + } + } + } + } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..5b58b43 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/settings/SettingsScreen.kt @@ -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) } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TasksScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TasksScreen.kt new file mode 100644 index 0000000..356b066 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TasksScreen.kt @@ -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 { repo.tasks() } + var refreshing by remember { mutableStateOf(false) } + var refreshNote by remember { mutableStateOf(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}") } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/timer/TimerScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/timer/TimerScreen.kt new file mode 100644 index 0000000..bcee1d9 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/timer/TimerScreen.kt @@ -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, + 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(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 + } +} diff --git a/android/app/src/main/res/drawable/ic_glyph_toy_preview.xml b/android/app/src/main/res/drawable/ic_glyph_toy_preview.xml new file mode 100644 index 0000000..3856337 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_glyph_toy_preview.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..6d2a0be --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8e87789..28d767f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,30 @@ Kadence + + + Running interval + + The live countdown while a focus interval or break is running. Silent. + + Interval finished + + Rings when a focus interval or break reaches zero. + + + + Pause + Resume + Finish + +5 min + Start break + Start focus + + + Kadence + + 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. + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..945e2e4 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/android/app/src/noglyph/kotlin/tech/kadence/android/glyph/NoGlyphVariant.kt b/android/app/src/noglyph/kotlin/tech/kadence/android/glyph/NoGlyphVariant.kt new file mode 100644 index 0000000..bb93231 --- /dev/null +++ b/android/app/src/noglyph/kotlin/tech/kadence/android/glyph/NoGlyphVariant.kt @@ -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 diff --git a/android/app/src/test/kotlin/tech/kadence/android/PrefsTest.kt b/android/app/src/test/kotlin/tech/kadence/android/PrefsTest.kt new file mode 100644 index 0000000..97753b1 --- /dev/null +++ b/android/app/src/test/kotlin/tech/kadence/android/PrefsTest.kt @@ -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(" ")) + } +} diff --git a/android/fetch-glyph-sdk.sh b/android/fetch-glyph-sdk.sh new file mode 100755 index 0000000..d82e382 --- /dev/null +++ b/android/fetch-glyph-sdk.sh @@ -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." diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 1b9025a..1ccef41 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -17,6 +17,10 @@ robolectric = "4.14.1" serialization = "1.11.0" okhttp = "5.5.0" 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] 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" } 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-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] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }