From 23ac45343cd196f31e58b37eb9dbd0361b08dd7d Mon Sep 17 00:00:00 2001
From: Binesh Ellupurayil Balachandran
<31845423+binesheb@users.noreply.github.com>
Date: Mon, 10 Aug 2026 21:46:26 +0530
Subject: [PATCH 01/10] Improve WHOOP call haptics and vNext architecture
Merge the first NOOP Next reliability slice into main: connection-aware call haptics, finite self-healing alert policy, tests, and the vNext product/UI roadmap.
---
.../com/noop/notif/CallAlertController.kt | 49 ++++++++----
.../java/com/noop/notif/CallAlertPolicy.kt | 19 ++++-
.../com/noop/notif/CallAlertPolicyTest.kt | 27 +++++--
docs/NOOP_NEXT.md | 79 +++++++++++++++++++
4 files changed, 151 insertions(+), 23 deletions(-)
create mode 100644 docs/NOOP_NEXT.md
diff --git a/android/app/src/main/java/com/noop/notif/CallAlertController.kt b/android/app/src/main/java/com/noop/notif/CallAlertController.kt
index 08c62a3beb..d5532ec530 100644
--- a/android/app/src/main/java/com/noop/notif/CallAlertController.kt
+++ b/android/app/src/main/java/com/noop/notif/CallAlertController.kt
@@ -13,15 +13,18 @@ internal enum class CallAlertSource {
/**
* Shared call-buzz coordinator for native phone state and strict VoIP notifications.
- * Multiple sources can be active at once, but only one throttled repeat loop drives the strap.
+ *
+ * This is intentionally a small state machine: phone/VoIP sources acquire tokens, while one
+ * scheduler owns the physical WHOOP actuator. That prevents two simultaneous call sources from
+ * doubling the haptic traffic.
*/
internal object CallAlertController {
- /** Hard ceiling on how long one call cycle can hold a token. A stop event isn't guaranteed — a
- * PHONE_STATE=IDLE broadcast can be dropped, and a VoIP notification can be removed without
- * onNotificationRemoved. Without this, a leaked token makes `start()` see `wasInactive = false`
- * forever and silently swallow the NEXT call's alert until a process restart. Auto-clear after
- * this window (re-armed on each sign of life from a call source). */
- private const val MAX_RING_WINDOW_MS = 60_000L
+ /**
+ * Hard ceiling on one call cycle. A stop event is not guaranteed — PHONE_STATE=IDLE can be
+ * dropped and a VoIP notification can disappear without a removal callback. Five minutes is
+ * long enough for legitimate calls while still self-healing a leaked token.
+ */
+ private const val MAX_RING_WINDOW_MS = 5 * 60_000L
private val handler = Handler(Looper.getMainLooper())
private val policy = CallAlertPolicy()
@@ -37,17 +40,21 @@ internal object CallAlertController {
}
}
- /** Self-heal: clear all sources if a stop event was missed (see [MAX_RING_WINDOW_MS]). */
+ /** Self-heal a leaked source if Android never delivers its stop event. */
private val maxRingRunnable = Runnable { stopAll() }
fun start(context: Context, source: CallAlertSource, key: String = source.name): Boolean {
if (!sourceEnabled(context, source)) return false
appContext = context.applicationContext
+ val token = "${source.name}:$key"
val wasInactive = activeTokens.isEmpty()
- activeTokens.add("${source.name}:$key")
- // (Re)arm the self-heal watchdog on every sign of life so a leaked token can't wedge the feature.
+ activeTokens.add(token)
+
+ // Re-arm the watchdog whenever the source reports life. This prevents a dropped stop
+ // event from permanently wedging the next call cycle.
handler.removeCallbacks(maxRingRunnable)
handler.postDelayed(maxRingRunnable, MAX_RING_WINDOW_MS)
+
if (wasInactive) {
buzzCount = 0
lastBuzzAtMs = null
@@ -74,15 +81,24 @@ internal object CallAlertController {
private fun maybeBuzz(context: Context) {
pruneDisabledSources(context)
- val active = activeTokens.isNotEmpty()
+ if (activeTokens.isEmpty()) return
+
val now = System.currentTimeMillis()
- if (!policy.shouldBuzz(active, buzzCount, lastBuzzAtMs, now)) return
+ if (!policy.shouldBuzz(true, buzzCount, lastBuzzAtMs, now)) return
+
+ // Do not consume a call-alert slot while the strap is temporarily disconnected. The
+ // existing connection service can recover the BLE link, and the next policy tick will
+ // retry the same call instead of silently losing the alert.
if (!deliveryAllowed(context)) {
scheduleNext()
return
}
- val ble = (context.applicationContext as? NoopApplication)?.ble ?: return
+ val ble = (context.applicationContext as? NoopApplication)?.ble ?: run {
+ scheduleNext()
+ return
+ }
+
ble.buzz(NotifPrefs.callLoops(context))
buzzCount += 1
lastBuzzAtMs = now
@@ -127,8 +143,13 @@ internal object CallAlertController {
if (!NotifPrefs.getBool(context, NotifPrefs.MASTER, false)) return false
if (!NotifPrefs.getBool(context, NotifPrefs.CALLS_MASTER, false)) return false
if (NotifPrefs.inQuietHours(context)) return false
+
val ble = (context.applicationContext as? NoopApplication)?.ble ?: return false
- if (NotifPrefs.getBool(context, NotifPrefs.WORN, true) && !ble.state.value.worn) return false
+ val state = ble.state.value
+ // `bonded` can remain true after a transient GATT disconnect. A haptic command sent in
+ // that window would be lost but would still count as a buzz, so require an active link.
+ if (!state.connected || !state.bonded) return false
+ if (NotifPrefs.getBool(context, NotifPrefs.WORN, true) && !state.worn) return false
return true
}
}
diff --git a/android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt b/android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt
index 46da87aaf0..07a744a899 100644
--- a/android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt
+++ b/android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt
@@ -1,13 +1,24 @@
package com.noop.notif
/**
- * Small pure policy for repeated call buzzes. The controller owns Android scheduling;
- * this object keeps the cadence testable.
+ * Pure policy for repeated call buzzes.
+ *
+ * The controller owns Android scheduling and BLE delivery. Keeping the cadence here makes
+ * the behaviour deterministic and unit-testable.
+ *
+ * Defaults are deliberately conservative: an incoming call gets an immediate buzz, followed
+ * by a small number of reminders. We do not buzz indefinitely because the WHOOP motor is a
+ * wearable haptic actuator and a never-ending loop would be intrusive and waste battery.
*/
internal data class CallAlertPolicy(
- val repeatIntervalMs: Long = 8_000L,
- val maxBuzzes: Int = 4,
+ val repeatIntervalMs: Long = 6_000L,
+ val maxBuzzes: Int = 6,
) {
+ init {
+ require(repeatIntervalMs > 0) { "repeatIntervalMs must be positive" }
+ require(maxBuzzes >= 1) { "maxBuzzes must be at least 1" }
+ }
+
fun shouldBuzz(active: Boolean, buzzCount: Int, lastBuzzAtMs: Long?, nowMs: Long): Boolean {
if (!active || buzzCount >= maxBuzzes) return false
return lastBuzzAtMs == null || nowMs - lastBuzzAtMs >= repeatIntervalMs
diff --git a/android/app/src/test/java/com/noop/notif/CallAlertPolicyTest.kt b/android/app/src/test/java/com/noop/notif/CallAlertPolicyTest.kt
index 8f23ec839d..1bbda0da36 100644
--- a/android/app/src/test/java/com/noop/notif/CallAlertPolicyTest.kt
+++ b/android/app/src/test/java/com/noop/notif/CallAlertPolicyTest.kt
@@ -6,7 +6,14 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class CallAlertPolicyTest {
- private val policy = CallAlertPolicy(repeatIntervalMs = 8_000L, maxBuzzes = 4)
+ private val policy = CallAlertPolicy(repeatIntervalMs = 6_000L, maxBuzzes = 6)
+
+ @Test
+ fun defaultsAreSuitableForIncomingCalls() {
+ val defaults = CallAlertPolicy()
+ assertEquals(6_000L, defaults.repeatIntervalMs)
+ assertEquals(6, defaults.maxBuzzes)
+ }
@Test
fun buzzesImmediatelyForActiveCall() {
@@ -15,14 +22,24 @@ class CallAlertPolicyTest {
@Test
fun throttlesUntilRepeatInterval() {
- assertFalse(policy.shouldBuzz(active = true, buzzCount = 1, lastBuzzAtMs = 1_000L, nowMs = 8_999L))
- assertTrue(policy.shouldBuzz(active = true, buzzCount = 1, lastBuzzAtMs = 1_000L, nowMs = 9_000L))
+ assertFalse(policy.shouldBuzz(active = true, buzzCount = 1, lastBuzzAtMs = 1_000L, nowMs = 6_999L))
+ assertTrue(policy.shouldBuzz(active = true, buzzCount = 1, lastBuzzAtMs = 1_000L, nowMs = 7_000L))
}
@Test
fun stopsAfterMaxBuzzesOrInactiveCall() {
- assertFalse(policy.shouldBuzz(active = true, buzzCount = 4, lastBuzzAtMs = 1_000L, nowMs = 20_000L))
+ assertFalse(policy.shouldBuzz(active = true, buzzCount = 6, lastBuzzAtMs = 1_000L, nowMs = 20_000L))
assertFalse(policy.shouldBuzz(active = false, buzzCount = 0, lastBuzzAtMs = null, nowMs = 1_000L))
- assertEquals(null, policy.nextDelayMs(buzzCount = 4))
+ assertEquals(null, policy.nextDelayMs(buzzCount = 6))
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun rejectsZeroRepeatInterval() {
+ CallAlertPolicy(repeatIntervalMs = 0L)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun rejectsZeroBuzzLimit() {
+ CallAlertPolicy(maxBuzzes = 0)
}
}
diff --git a/docs/NOOP_NEXT.md b/docs/NOOP_NEXT.md
new file mode 100644
index 0000000000..b17e747395
--- /dev/null
+++ b/docs/NOOP_NEXT.md
@@ -0,0 +1,79 @@
+# NOOP Next — product and engineering direction
+
+NOOP should evolve as a local-first WHOOP companion rather than becoming a second cloud service.
+The guiding rule is: **phone events → local policy → WHOOP BLE haptic/data path**.
+
+## vNext goals
+
+### 1. Call alerts that feel native
+
+- Native phone calls are the highest-priority alert source.
+- VoIP calls remain a separate source so a Teams/WhatsApp/Zoom call cannot accidentally enable native phone-call permission.
+- The call state machine must be idempotent: duplicate `RINGING` events do not multiply buzzes.
+- A disconnected strap must not consume a buzz slot. The connection service gets time to recover, then the same alert is retried.
+- A missed `IDLE`/notification-removal event must self-heal with a watchdog.
+- Default cadence: immediate buzz, then up to 5 reminders at 6-second intervals. This is intentionally finite.
+- Respect NOOP master notifications, call master, quiet hours, and wear-gating before sending a haptic.
+
+### 2. WHOOP haptics
+
+Use the existing, hardware-verified haptic path instead of inventing a new BLE command.
+
+WHOOP 4.0 uses `RUN_HAPTICS_PATTERN` (command 79) with the payload
+`[patternId, loops, 0, 0, 0]`; NOOP already uses pattern 2 for its graduated alarm-style buzz.
+WHOOP 5/MG has a separately remapped haptic path in the BLE client and must retain its family gate.
+
+Do not send destructive/firmware commands from notification code.
+
+### 3. Better UI
+
+The Notifications screen should become an event-control center instead of a long settings list.
+
+Proposed hierarchy:
+
+1. **Wrist Alerts** — one master state and live delivery status.
+2. **Calls** — one large card with Phone Calls and VoIP Calls as independent switches.
+3. **Test Buzz** — one-tap physical test with the current pattern.
+4. **Apps** — grouped apps with search, enabled state, and pattern preview.
+5. **Quiet Hours** — a single visual time-range control.
+6. **Diagnostics** — last event, last delivery, connection state, and permission state.
+
+The call card should make the important path obvious:
+
+`Phone rings → NOOP detects it → WHOOP connected → 3-pulse buzz`
+
+If any step is unavailable, show the exact blocker and a single action to fix it.
+
+### 4. Notification reliability
+
+Android's `NotificationListenerService` is the correct local mechanism for app notifications.
+It receives notification posted/removed callbacks and requires the user to grant Notification Access.
+Native phone calls should remain on the phone-state path rather than depending on notification text.
+
+### 5. Privacy
+
+- No notification contents leave the device.
+- No phone numbers are uploaded.
+- No cloud relay is required for call haptics.
+- Store only the minimum state required to make the local event machine reliable.
+
+## Acceptance tests for call haptics
+
+1. Incoming GSM call → WHOOP buzzes immediately.
+2. Call remains ringing → reminders occur every 6 seconds, up to the configured finite limit.
+3. WHOOP disconnects while ringing → no buzz slot is lost; delivery resumes after reconnection.
+4. WHOOP is not worn and wear-gating is enabled → no buzz.
+5. Quiet hours are active → no buzz.
+6. Call ends → the repeat loop stops immediately.
+7. Duplicate `RINGING` broadcasts → no duplicate haptic storm.
+8. A missed stop event → watchdog clears the token and the next call can alert.
+9. VoIP and GSM calls overlapping → one shared haptic scheduler, not two independent loops.
+10. WHOOP 4.0 and WHOOP 5/MG use their existing family-specific haptic transport.
+
+## Research references
+
+- Android `NotificationListenerService`: https://developer.android.com/reference/android/service/notification/NotificationListenerService
+- NOOP Android protocol notes: https://github.com/ryanbr/noop/blob/main/docs/ANDROID.md
+- WHOOP BLE reverse-engineering notes: https://www.rusheelraj.com/blog/whoop/
+
+These references are used for architecture/protocol validation; the implementation remains in NOOP's local-first codebase.
From e7f68a7daff450cbe84aabc9cbfc678b2ca03507 Mon Sep 17 00:00:00 2001
From: Binesh Ellupurayil Balachandran
<31845423+binesheb@users.noreply.github.com>
Date: Mon, 10 Aug 2026 21:50:53 +0530
Subject: [PATCH 02/10] Rebuild Android notification control centre
Introduce the NOOP Next notification control centre with live wrist status, call controls, app patterns, behaviour rules, and preserved notification preferences.
---
.../com/noop/ui/NotificationSettingsModel.kt | 109 ++
.../noop/ui/NotificationsSettingsScreen.kt | 1146 +++++------------
.../app/src/main/java/com/noop/ui/TimeChip.kt | 94 ++
3 files changed, 509 insertions(+), 840 deletions(-)
create mode 100644 android/app/src/main/java/com/noop/ui/NotificationSettingsModel.kt
create mode 100644 android/app/src/main/java/com/noop/ui/TimeChip.kt
diff --git a/android/app/src/main/java/com/noop/ui/NotificationSettingsModel.kt b/android/app/src/main/java/com/noop/ui/NotificationSettingsModel.kt
new file mode 100644
index 0000000000..3348bb6765
--- /dev/null
+++ b/android/app/src/main/java/com/noop/ui/NotificationSettingsModel.kt
@@ -0,0 +1,109 @@
+package com.noop.ui
+
+import android.content.Context
+import java.util.Calendar
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Chat
+import androidx.compose.material.icons.filled.Alarm
+import androidx.compose.material.icons.filled.CalendarMonth
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.Groups
+import androidx.compose.material.icons.filled.Message
+import androidx.compose.material.icons.filled.Videocam
+import androidx.compose.ui.graphics.vector.ImageVector
+
+/** Haptic shapes exposed to the notification UI. */
+internal enum class BuzzPattern(val label: String, val loops: Int) {
+ Single("Single", 1),
+ Double("Double", 2),
+ Triple("Triple", 3),
+ Long("Long", 5),
+}
+
+internal enum class NotifCategory(
+ val title: String,
+ val icon: ImageVector,
+ val defaultPattern: BuzzPattern,
+) {
+ Calls("Calls", Icons.Filled.Message, BuzzPattern.Triple),
+ Messaging("Messages", Icons.AutoMirrored.Filled.Chat, BuzzPattern.Single),
+ Email("Email", Icons.Filled.Email, BuzzPattern.Double),
+ Meetings("Meetings", Icons.Filled.Videocam, BuzzPattern.Triple),
+ Calendar("Calendar", Icons.Filled.CalendarMonth, BuzzPattern.Double),
+}
+
+internal data class NotifApp(
+ val id: String,
+ val name: String,
+ val category: NotifCategory,
+ val glyph: ImageVector,
+)
+
+internal val notifCatalog: List = listOf(
+ NotifApp("com.whatsapp", "WhatsApp", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat),
+ NotifApp("org.telegram.messenger", "Telegram", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat),
+ NotifApp("com.google.android.apps.messaging", "Messages", NotifCategory.Messaging, Icons.Filled.Message),
+ NotifApp("com.Slack", "Slack", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat),
+ NotifApp("com.microsoft.teams", "Microsoft Teams", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat),
+ NotifApp("com.google.android.gm", "Gmail", NotifCategory.Email, Icons.Filled.Email),
+ NotifApp("com.microsoft.office.outlook", "Outlook", NotifCategory.Email, Icons.Filled.Email),
+ NotifApp("us.zoom.videomeetings", "Zoom", NotifCategory.Meetings, Icons.Filled.Videocam),
+ NotifApp("com.google.android.calendar", "Calendar", NotifCategory.Calendar, Icons.Filled.CalendarMonth),
+)
+
+internal object NotifPrefs {
+ private const val FILE = "noop_notif_prefs"
+ const val MASTER = "notif.masterEnabled"
+ const val ALL_OTHER = "notif.allOtherApps"
+ const val WORN = "notif.onlyWhenWorn"
+ const val QUIET = "notif.quietHoursEnabled"
+ const val QUIET_START = "notif.quietStartMinutes"
+ const val QUIET_END = "notif.quietEndMinutes"
+ const val CALLS_MASTER = "notif.calls.masterEnabled"
+ const val CALLS_PHONE = "notif.calls.phoneEnabled"
+ const val CALLS_VOIP = "notif.calls.voipEnabled"
+ const val CALLS_PATTERN = "notif.calls.pattern"
+ const val ALARM_TIMER = "notif.alarmTimer"
+
+ private fun prefs(ctx: Context) = ctx.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
+
+ fun getBool(ctx: Context, key: String, default: Boolean) = prefs(ctx).getBoolean(key, default)
+ fun setBool(ctx: Context, key: String, value: Boolean) = prefs(ctx).edit().putBoolean(key, value).apply()
+ fun getInt(ctx: Context, key: String, default: Int) = prefs(ctx).getInt(key, default)
+ fun setInt(ctx: Context, key: String, value: Int) = prefs(ctx).edit().putInt(key, value).apply()
+
+ fun appEnabled(ctx: Context, id: String) = prefs(ctx).getBoolean("app.$id.enabled", false)
+ fun setAppEnabled(ctx: Context, id: String, value: Boolean) = prefs(ctx).edit().putBoolean("app.$id.enabled", value).apply()
+
+ fun appPattern(ctx: Context, app: NotifApp): BuzzPattern {
+ val saved = prefs(ctx).getString("app.${app.id}.pattern", null)
+ return BuzzPattern.entries.firstOrNull { it.name == saved } ?: app.category.defaultPattern
+ }
+
+ fun setAppPattern(ctx: Context, id: String, pattern: BuzzPattern) =
+ prefs(ctx).edit().putString("app.$id.pattern", pattern.name).apply()
+
+ fun appLoops(ctx: Context, pkg: String): Int {
+ val saved = prefs(ctx).getString("app.$pkg.pattern", null)
+ return BuzzPattern.entries.firstOrNull { it.name == saved }?.loops ?: BuzzPattern.Double.loops
+ }
+
+ fun callPattern(ctx: Context): BuzzPattern {
+ val saved = prefs(ctx).getString(CALLS_PATTERN, null)
+ return BuzzPattern.entries.firstOrNull { it.name == saved } ?: BuzzPattern.Triple
+ }
+
+ fun setCallPattern(ctx: Context, pattern: BuzzPattern) =
+ prefs(ctx).edit().putString(CALLS_PATTERN, pattern.name).apply()
+
+ fun callLoops(ctx: Context) = callPattern(ctx).loops
+
+ fun inQuietHours(ctx: Context): Boolean {
+ if (!getBool(ctx, QUIET, false)) return false
+ val start = getInt(ctx, QUIET_START, 22 * 60)
+ val end = getInt(ctx, QUIET_END, 7 * 60)
+ val nowCal = Calendar.getInstance()
+ val now = nowCal.get(Calendar.HOUR_OF_DAY) * 60 + nowCal.get(Calendar.MINUTE)
+ return if (start <= end) now in start until end else now >= start || now < end
+ }
+}
diff --git a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
index cb19ad715f..3e6a4afd68 100644
--- a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
@@ -1,7 +1,5 @@
package com.noop.ui
-import com.noop.R
-import androidx.compose.ui.res.stringResource
import android.Manifest
import android.content.Context
import android.content.Intent
@@ -18,34 +16,28 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Alarm
import androidx.compose.material.icons.filled.Call
-import androidx.compose.material.icons.automirrored.filled.Chat
-import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.NotificationsActive
-import androidx.compose.material.icons.automirrored.filled.OpenInNew
-import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.OpenInNew
+import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Tune
-import androidx.compose.material.icons.filled.Videocam
+import androidx.compose.material.icons.filled.Watch
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
-import androidx.compose.material3.TimePicker
-import androidx.compose.material3.TimePickerDefaults
-import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
@@ -59,203 +51,35 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.semantics.contentDescription
-import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.noop.ble.WhoopModel
import com.noop.notif.CallAlertController
import com.noop.notif.CallAlertSource
-import java.util.Calendar
-
-// MARK: - NotificationsSettingsScreen
-//
-// Android port of NotificationSettingsView.swift. Choose which apps tap your wrist and
-// how (per-app buzz pattern), with a master switch and overnight quiet hours.
-//
-// macOS resolves real installed apps via LaunchServices/NSWorkspace. Android restricts
-// package visibility (API 30+) and there is no equivalent "notification-capable app"
-// query, so we ship a curated catalog of common notification apps grouped exactly like
-// the Mac screen. Preferences persist in SharedPreferences (the Android counterpart to
-// UserDefaults); when the background bridge ships it reads the same prefs.
-//
-// Delivery requires a NotificationListenerService with Notification Access granted — the
-// behaviour card deep-links to Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS for that.
-
-// MARK: - Domain model (mirrors NotificationSettingsStore.swift)
-
-/** Haptic pattern fired on the strap; only the repeat count varies. */
-internal enum class BuzzPattern(val label: String, val loops: Int) {
- Single("Single", 1),
- Double("Double", 2),
- Triple("Triple", 3),
- Long("Long", 5),
-}
-
-/** Grouping for the settings screen, with its header icon + default pattern. */
-internal enum class NotifCategory(
- val title: String,
- val icon: ImageVector,
- val defaultPattern: BuzzPattern,
-) {
- Email("Email", Icons.Filled.Email, BuzzPattern.Double),
- Messaging("Messaging", Icons.AutoMirrored.Filled.Chat, BuzzPattern.Single),
- Meetings("Meetings", Icons.Filled.Videocam, BuzzPattern.Triple),
- Calendar("Calendar & Reminders", Icons.Filled.CalendarMonth, BuzzPattern.Double),
-}
-
-/** A notification-capable app NOOP can mirror to the wrist. `id` is the persistence key. */
-internal data class NotifApp(
- val id: String,
- val name: String,
- val category: NotifCategory,
- val glyph: ImageVector,
-)
/**
- * Curated catalog of common Android notification apps, grouped to match the Mac screen.
- * Unlike macOS we cannot enumerate which are actually installed (restricted package
- * visibility), so we present the full set as configurable examples.
+ * NOOP Next notification control centre.
+ *
+ * The previous screen mixed every setting into a long list. This version puts the important
+ * path first: enable wrist alerts, verify the strap, configure calls, then configure apps.
+ * Existing preference keys are intentionally preserved so upgrades don't reset the user's choices.
*/
-private val notifCatalog: List = listOf(
- NotifApp("com.google.android.gm", "Gmail", NotifCategory.Email, Icons.Filled.Email),
- NotifApp("com.microsoft.office.outlook", "Outlook", NotifCategory.Email, Icons.Filled.Email),
- NotifApp("com.whatsapp", "WhatsApp", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat),
- NotifApp("com.google.android.apps.messaging", "Messages", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat),
- NotifApp("com.Slack", "Slack", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat),
- NotifApp("org.telegram.messenger", "Telegram", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat),
- // Teams' ringing-call notifications are handled by the Calls card below (VoIP path). This
- // per-app row covers everything else Teams sends to the shade (chats, @-mentions, channel
- // posts), which read as messages, so it lives under Messaging with the chat glyph.
- NotifApp("com.microsoft.teams", "Microsoft Teams", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat),
- NotifApp("us.zoom.videomeetings", "Zoom", NotifCategory.Meetings, Icons.Filled.Videocam),
- NotifApp("com.google.android.calendar", "Calendar", NotifCategory.Calendar, Icons.Filled.CalendarMonth),
-)
-
-private fun appsIn(category: NotifCategory): List =
- notifCatalog.filter { it.category == category }
-
-private val activeCategories: List =
- NotifCategory.entries.filter { appsIn(it).isNotEmpty() }
-
-// MARK: - SharedPreferences store (mirrors the UserDefaults-backed Swift store)
-
-/**
- * Plain-prefs store for wrist-alert settings (the AI key uses encrypted prefs; these are
- * non-secret toggles). Per-app prefs are flattened to `app..enabled` / `app..pattern`
- * keys so no JSON dependency is needed.
- */
-internal object NotifPrefs {
- private const val FILE = "noop_notif_prefs"
- const val MASTER = "notif.masterEnabled"
- /** Catch-all: buzz for any app NOT in the curated catalog (Android can't enumerate installed
- * apps, so this is how a user covers BeReal/etc. that aren't listed). Opt-in, default OFF. (#168) */
- const val ALL_OTHER = "notif.allOtherApps"
- const val WORN = "notif.onlyWhenWorn"
- const val QUIET = "notif.quietHoursEnabled"
- const val QUIET_START = "notif.quietStartMinutes"
- const val QUIET_END = "notif.quietEndMinutes"
- const val CALLS_MASTER = "notif.calls.masterEnabled"
- const val CALLS_PHONE = "notif.calls.phoneEnabled"
- const val CALLS_VOIP = "notif.calls.voipEnabled"
- const val CALLS_PATTERN = "notif.calls.pattern"
- /** Buzz the strap when the phone's native Clock fires a timer/alarm (CATEGORY_ALARM). Android-only
- * (iOS can't observe another app's notifications). Default OFF. */
- const val ALARM_TIMER = "notif.alarmTimer"
-
- private fun prefs(ctx: Context) =
- ctx.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
-
- fun getBool(ctx: Context, key: String, default: Boolean) =
- prefs(ctx).getBoolean(key, default)
-
- fun setBool(ctx: Context, key: String, value: Boolean) =
- prefs(ctx).edit().putBoolean(key, value).apply()
-
- fun getInt(ctx: Context, key: String, default: Int) =
- prefs(ctx).getInt(key, default)
-
- fun setInt(ctx: Context, key: String, value: Int) =
- prefs(ctx).edit().putInt(key, value).apply()
-
- fun appEnabled(ctx: Context, id: String): Boolean =
- prefs(ctx).getBoolean("app.$id.enabled", false) // opt-in, default OFF
-
- fun setAppEnabled(ctx: Context, id: String, value: Boolean) =
- prefs(ctx).edit().putBoolean("app.$id.enabled", value).apply()
-
- fun appPattern(ctx: Context, app: NotifApp): BuzzPattern {
- val name = prefs(ctx).getString("app.${app.id}.pattern", null)
- return BuzzPattern.entries.firstOrNull { it.name == name } ?: app.category.defaultPattern
- }
-
- fun setAppPattern(ctx: Context, id: String, pattern: BuzzPattern) =
- prefs(ctx).edit().putString("app.$id.pattern", pattern.name).apply()
-
- /** Buzz loop-count for [pkg] (for the notification listener; no NotifApp needed). Defaults to
- * Double if no per-app pattern was chosen. */
- fun appLoops(ctx: Context, pkg: String): Int {
- val name = prefs(ctx).getString("app.$pkg.pattern", null)
- return BuzzPattern.entries.firstOrNull { it.name == name }?.loops ?: BuzzPattern.Double.loops
- }
-
- fun callPattern(ctx: Context): BuzzPattern {
- val name = prefs(ctx).getString(CALLS_PATTERN, null)
- return BuzzPattern.entries.firstOrNull { it.name == name } ?: BuzzPattern.Triple
- }
-
- fun setCallPattern(ctx: Context, pattern: BuzzPattern) =
- prefs(ctx).edit().putString(CALLS_PATTERN, pattern.name).apply()
-
- fun callLoops(ctx: Context): Int = callPattern(ctx).loops
-
- fun inQuietHours(ctx: Context): Boolean {
- if (!getBool(ctx, QUIET, false)) return false
- val start = getInt(ctx, QUIET_START, 22 * 60)
- val end = getInt(ctx, QUIET_END, 7 * 60)
- val cal = Calendar.getInstance()
- val now = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE)
- // Quiet window may wrap midnight (e.g. 22:00 -> 07:00).
- return if (start <= end) now in start until end else (now >= start || now < end)
- }
-}
-
-// MARK: - Screen
-
@Composable
fun NotificationsSettingsScreen(vm: AppViewModel) {
val context = LocalContext.current
val live by vm.live.collectAsStateWithLifecycle()
- // Header settings, seeded from prefs once and written through on change.
- var masterEnabled by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.MASTER, false)) }
- var onlyWhenWorn by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.WORN, true)) }
- var allOtherApps by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.ALL_OTHER, false)) }
- var quietHoursEnabled by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.QUIET, false)) }
- var quietStartMinutes by remember { mutableStateOf(NotifPrefs.getInt(context, NotifPrefs.QUIET_START, 22 * 60)) }
- var quietEndMinutes by remember { mutableStateOf(NotifPrefs.getInt(context, NotifPrefs.QUIET_END, 7 * 60)) }
- var callsEnabled by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_MASTER, false)) }
- var phoneCallsEnabled by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_PHONE, false)) }
- var voipCallsEnabled by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_VOIP, false)) }
- var alarmTimerEnabled by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.ALARM_TIMER, false)) }
- var callsPattern by remember { mutableStateOf(NotifPrefs.callPattern(context)) }
- // Scheduled report notifications (#517) — opt-in, default OFF. SharedPreferences isn't reactive, so
- // each Switch mirrors into local state and writes straight through to NoopPrefs.
- var morningReport by remember { mutableStateOf(NoopPrefs.morningReportEnabled(context)) }
- var postWorkoutReport by remember { mutableStateOf(NoopPrefs.postWorkoutReportEnabled(context)) }
- var strainTargetReport by remember { mutableStateOf(NoopPrefs.strainTargetEnabled(context)) }
- var phonePermissionDenied by remember { mutableStateOf(false) }
- val phonePermissionLauncher = rememberLauncherForActivityResult(
- ActivityResultContracts.RequestPermission(),
- ) { granted ->
- phoneCallsEnabled = granted
- phonePermissionDenied = !granted
- NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, granted)
- }
+ var master by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.MASTER, false)) }
+ var calls by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_MASTER, false)) }
+ var phoneCalls by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_PHONE, false)) }
+ var voipCalls by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_VOIP, false)) }
+ var wornOnly by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.WORN, true)) }
+ var quiet by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.QUIET, false)) }
+ var alarmTimer by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.ALARM_TIMER, false)) }
+ var allOther by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.ALL_OTHER, false)) }
+ var callPattern by remember { mutableStateOf(NotifPrefs.callPattern(context)) }
+ var permissionDenied by remember { mutableStateOf(false) }
- // Per-app enabled state, seeded from prefs so the UI is reactive within the session.
val enabledState: SnapshotStateMap = remember {
mutableStateMapOf().apply {
notifCatalog.forEach { put(it.id, NotifPrefs.appEnabled(context, it.id)) }
@@ -266,469 +90,252 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
notifCatalog.forEach { put(it.id, NotifPrefs.appPattern(context, it)) }
}
}
- val enabledCount = enabledState.values.count { it }
- ScreenScaffold(
- title = uiString(R.string.l10n_notifications_settings_screen_notifications_753a22b2),
- subtitle = "Buzz your strap when these apps notify you. Everything runs on this device.",
- ) {
- // MARK: Master card
- AlertSection(
- icon = Icons.Filled.NotificationsActive,
- title = uiString(R.string.l10n_notifications_settings_screen_wrist_alerts_75581d51),
- blurb = "When on, NOOP taps your wrist for the apps you pick below, so you can leave " +
- "your phone and still feel what matters.",
- ) {
- Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
- Text(uiString(R.string.l10n_notifications_settings_screen_enable_wrist_alerts_462b9e0f), style = NoopType.body, color = Palette.textPrimary)
- Spacer(Modifier.weight(1f))
- NoopSwitch(
- checked = masterEnabled,
- onChange = {
- masterEnabled = it
- NotifPrefs.setBool(context, NotifPrefs.MASTER, it)
- },
- label = uiString(R.string.l10n_notifications_settings_screen_enable_wrist_alerts_462b9e0f),
- )
- }
+ val phonePermissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { granted ->
+ phoneCalls = granted
+ permissionDenied = !granted
+ NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, granted)
+ }
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- StatePill(strapPillTitle(live), tone = strapPillTone(live), pulsing = live.connected)
- StatePill(
- "$enabledCount app${if (enabledCount == 1) "" else "s"} on",
- tone = if (enabledCount > 0) StrandTone.Positive else StrandTone.Neutral,
- showsDot = false,
- )
- Spacer(Modifier.weight(1f))
- PillButton(
- label = uiString(R.string.l10n_notifications_settings_screen_test_buzz_deeab5ae),
- icon = Icons.Filled.GraphicEq,
- enabled = live.bonded,
- onClick = { vm.buzz(loops = 2) },
- )
- }
+ val enabledApps = enabledState.values.count { it }
+ val deliveryReady = master && live.connected && (!wornOnly || live.worn) && !quiet
- DeliveryNote()
- }
+ ScreenScaffold(
+ title = "Notifications",
+ subtitle = "Make the information you need reach your wrist.",
+ ) {
+ NotificationHero(
+ enabled = master,
+ connected = live.connected,
+ worn = live.worn,
+ ready = deliveryReady,
+ enabledApps = enabledApps,
+ onMasterChange = {
+ master = it
+ NotifPrefs.setBool(context, NotifPrefs.MASTER, it)
+ if (!it) CallAlertController.stopAll()
+ },
+ onTest = { vm.buzz(loops = 2) },
+ )
- CallsCard(
- masterEnabled = masterEnabled,
- callsEnabled = callsEnabled,
- phoneCallsEnabled = phoneCallsEnabled,
- voipCallsEnabled = voipCallsEnabled,
- pattern = callsPattern,
+ CallsControlCard(
+ master = master,
+ enabled = calls,
+ phoneEnabled = phoneCalls,
+ voipEnabled = voipCalls,
+ pattern = callPattern,
bonded = live.bonded,
- permissionDenied = phonePermissionDenied,
- onCallsEnabled = {
- callsEnabled = it
+ permissionDenied = permissionDenied,
+ onEnabled = {
+ calls = it
NotifPrefs.setBool(context, NotifPrefs.CALLS_MASTER, it)
if (!it) CallAlertController.stopAll()
},
- onPhoneCallsEnabled = { value ->
+ onPhone = { value ->
if (!value) {
- phoneCallsEnabled = false
- phonePermissionDenied = false
+ phoneCalls = false
+ permissionDenied = false
NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, false)
CallAlertController.stopSource(CallAlertSource.PHONE)
- return@CallsCard
- }
- val granted = ContextCompat.checkSelfPermission(
- context,
- Manifest.permission.READ_PHONE_STATE,
- ) == PackageManager.PERMISSION_GRANTED
- if (granted) {
- phoneCallsEnabled = true
- phonePermissionDenied = false
- NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, true)
} else {
- phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE)
+ val granted = ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_PHONE_STATE,
+ ) == PackageManager.PERMISSION_GRANTED
+ if (granted) {
+ phoneCalls = true
+ permissionDenied = false
+ NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, true)
+ } else {
+ phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE)
+ }
}
},
- onVoipCallsEnabled = {
- voipCallsEnabled = it
+ onVoip = {
+ voipCalls = it
NotifPrefs.setBool(context, NotifPrefs.CALLS_VOIP, it)
if (!it) CallAlertController.stopSource(CallAlertSource.VOIP)
},
onPattern = {
- callsPattern = it
+ callPattern = it
NotifPrefs.setCallPattern(context, it)
},
- onTest = { vm.buzz(loops = callsPattern.loops) },
+ onTest = { vm.buzz(loops = callPattern.loops) },
)
- // #1115 follow-up: buzz the strap when the phone's native Clock fires a timer or alarm
- // (CATEGORY_ALARM, any clock app). Android-only — iOS can't observe another app's notifications.
- AlertSection(
- icon = Icons.Filled.Alarm,
- title = uiString(R.string.notif_timer_alarm_title),
- blurb = "Buzz your wrist when your phone's Clock app finishes a timer or rings an alarm.",
- ) {
- Column(modifier = Modifier.alphaIf(if (masterEnabled) 1f else Palette.disabledOpacity)) {
- FormToggleRow(
- label = uiString(R.string.notif_timer_alarm_title),
- help = "Requires wrist alerts (above) to be on. Buzzes once when a timer/alarm notification fires.",
- checked = alarmTimerEnabled,
- enabled = masterEnabled,
- onChange = {
- alarmTimerEnabled = it
- NotifPrefs.setBool(context, NotifPrefs.ALARM_TIMER, it)
- },
- )
- }
- }
+ AppsControlCard(
+ master = master,
+ enabledState = enabledState,
+ patternState = patternState,
+ onToggle = { app, value ->
+ enabledState[app.id] = value
+ NotifPrefs.setAppEnabled(context, app.id, value)
+ },
+ onPattern = { app, pattern ->
+ patternState[app.id] = pattern
+ NotifPrefs.setAppPattern(context, app.id, pattern)
+ },
+ onTest = { app -> vm.buzz(loops = (patternState[app.id] ?: app.category.defaultPattern).loops) },
+ )
- // #926: on a 5/MG the buzz payload is a hardcoded 12-byte literal and byte[11] (overallLoop) is
- // 0, so the caller's repeat count never reaches the wire — every BuzzPattern produces the same
- // buzz. The picker still offers all four because the choice is stored per app and DOES apply to a
- // WHOOP 4.0, so hiding it would lose a real setting. Say so instead, the way SmartAlarmScreen
- // handles its own 5/MG-unconfirmed case, rather than leaving a control that silently does nothing.
- // Match the Settings #22 gate (SettingsScreen showFiveMGControls, mirrored in TestCentreScreen:87):
- // the stored model OR a live-detected 5/MG this session. `live.whoop5Detected` alone is reset on
- // every scan and disconnect, so keying on it would hide this note whenever the strap is offline —
- // which is exactly when someone browses notification settings. The Apple side reads `selectedModel`,
- // which persists, so gating on live detection here would also make the two platforms disagree.
- val fiveMgSelected = remember {
- NoopPrefs.of(context).getString("noop.selectedWhoopModel", null) == WhoopModel.WHOOP5_MG.name
- }
- if (fiveMgSelected || live.whoop5Detected) {
- Text(
- uiString(R.string.l10n_notifications_settings_screen_every_pattern_buzzes_the_same_on_34e873f6),
- style = NoopType.caption,
- color = Palette.textSecondary,
- modifier = Modifier.padding(horizontal = Metrics.space16, vertical = Metrics.space8),
- )
- }
+ NotificationBehaviourCard(
+ master = master,
+ wornOnly = wornOnly,
+ quiet = quiet,
+ alarmTimer = alarmTimer,
+ allOther = allOther,
+ onWornOnly = {
+ wornOnly = it
+ NotifPrefs.setBool(context, NotifPrefs.WORN, it)
+ },
+ onQuiet = {
+ quiet = it
+ NotifPrefs.setBool(context, NotifPrefs.QUIET, it)
+ },
+ onAlarm = {
+ alarmTimer = it
+ NotifPrefs.setBool(context, NotifPrefs.ALARM_TIMER, it)
+ },
+ onAllOther = {
+ allOther = it
+ NotifPrefs.setBool(context, NotifPrefs.ALL_OTHER, it)
+ },
+ )
- // MARK: Category cards
- activeCategories.forEach { cat ->
- CategoryCard(
- category = cat,
- apps = appsIn(cat),
- masterEnabled = masterEnabled,
- bonded = live.bonded,
- enabledState = enabledState,
- patternState = patternState,
- onToggle = { app, value ->
- enabledState[app.id] = value
- NotifPrefs.setAppEnabled(context, app.id, value)
- },
- onPattern = { app, pattern ->
- patternState[app.id] = pattern
- NotifPrefs.setAppPattern(context, app.id, pattern)
- },
- onTest = { app -> vm.buzz(loops = (patternState[app.id] ?: app.category.defaultPattern).loops) },
- )
- }
+ AccessCard(context = context)
+ }
+}
- // MARK: Behaviour card
- AlertSection(
- icon = Icons.Filled.Tune,
- title = uiString(R.string.l10n_notifications_settings_screen_behaviour_171ca038),
- blurb = "Fine-tune when alerts reach your wrist.",
- ) {
- FormToggleRow(
- label = uiString(R.string.l10n_notifications_settings_screen_only_buzz_when_worn_6211cee3),
- help = "Skip alerts when the strap is off your wrist.",
- checked = onlyWhenWorn,
- onChange = {
- onlyWhenWorn = it
- NotifPrefs.setBool(context, NotifPrefs.WORN, it)
- },
- )
- RowDivider()
- FormToggleRow(
- label = uiString(R.string.l10n_notifications_settings_screen_all_other_apps_51a8af2c),
- help = "Also buzz for apps that aren't in the lists above (e.g. BeReal). Android " +
- "doesn't let NOOP see every installed app, so this is how you cover the rest. " +
- "Can be chatty; quiet hours and \"only when worn\" still apply.",
- checked = allOtherApps,
- onChange = {
- allOtherApps = it
- NotifPrefs.setBool(context, NotifPrefs.ALL_OTHER, it)
- },
- )
- RowDivider()
- FormToggleRow(
- label = uiString(R.string.l10n_notifications_settings_screen_quiet_hours_706b24d0),
- help = "Mute wrist alerts overnight.",
- checked = quietHoursEnabled,
- onChange = {
- quietHoursEnabled = it
- NotifPrefs.setBool(context, NotifPrefs.QUIET, it)
- },
- )
- if (quietHoursEnabled) {
- RowDivider()
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(uiString(R.string.l10n_notifications_settings_screen_from_3f66052a), style = NoopType.body, color = Palette.textPrimary)
- TimeChip(
- minutes = quietStartMinutes,
- accessibilityLabel = "Quiet hours start",
- onPicked = {
- quietStartMinutes = it
- NotifPrefs.setInt(context, NotifPrefs.QUIET_START, it)
- },
- )
- Text("to", style = NoopType.body, color = Palette.textSecondary)
- TimeChip(
- minutes = quietEndMinutes,
- accessibilityLabel = "Quiet hours end",
- onPicked = {
- quietEndMinutes = it
- NotifPrefs.setInt(context, NotifPrefs.QUIET_END, it)
+@Composable
+private fun NotificationHero(
+ enabled: Boolean,
+ connected: Boolean,
+ worn: Boolean,
+ ready: Boolean,
+ enabledApps: Int,
+ onMasterChange: (Boolean) -> Unit,
+ onTest: () -> Unit,
+) {
+ NoopCard(padding = 20.dp, tint = if (ready) Palette.accent else Palette.hairline) {
+ Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Overline("WRIST ALERTS", color = Palette.accent)
+ Text("Notification bridge", style = NoopType.title2, color = Palette.textPrimary)
+ Text(
+ when {
+ !enabled -> "Off — your WHOOP will stay quiet."
+ !connected -> "Waiting for your WHOOP to connect."
+ !worn -> "Strap connected, but not currently worn."
+ else -> "Ready — important events can reach your wrist."
},
+ style = NoopType.subhead,
+ color = Palette.textSecondary,
)
- Spacer(Modifier.weight(1f))
}
+ NoopSwitch(checked = enabled, onChange = onMasterChange, enabled = true, label = "Wrist alerts")
}
- }
- // MARK: Daily reports (#517) — phone notifications, not wrist buzzes. Opt-in, default OFF, no AI.
- AlertSection(
- icon = Icons.Filled.NotificationsActive,
- title = uiString(R.string.l10n_notifications_settings_screen_daily_reports_c1a22a74),
- blurb = "Optional phone notifications, off by default. These arrive after your strap syncs " +
- "and NOOP scores the data, so they land soon after, not the exact second you wake or " +
- "finish a workout. Everything is worked out on this phone.",
- ) {
- FormToggleRow(
- label = uiString(R.string.l10n_notifications_settings_screen_morning_recap_45ec05c5),
- help = "After last night is processed, a notification with your Charge and Rest. Posts " +
- "once a day, after your strap has synced the night.",
- checked = morningReport,
- onChange = {
- morningReport = it
- NoopPrefs.setMorningReportEnabled(context, it)
- },
- )
- RowDivider()
- FormToggleRow(
- label = uiString(R.string.l10n_notifications_settings_screen_post_workout_summary_13e488f5),
- help = "When a new workout syncs in, a notification with its Effort, duration and average " +
- "heart rate. Shows up after the session reaches NOOP on the next sync.",
- checked = postWorkoutReport,
- onChange = {
- postWorkoutReport = it
- NoopPrefs.setPostWorkoutReportEnabled(context, it)
- // Seed the frontier to the newest existing workout when turning ON, so enabling it
- // doesn't immediately fire a summary for a session already in history.
- if (it) vm.seedWorkoutReportFrontier()
- },
- )
- RowDivider()
- // #593: NOOP's own optimal-strain-reached nudge (not WHOOP's copy).
- FormToggleRow(
- label = uiString(R.string.l10n_notifications_settings_screen_optimal_strain_reached_2862ec2b),
- help = "Once a day, a notification when your Effort reaches the low end of today's optimal " +
- "strain range (from your recovery). Posts after your strap syncs and NOOP scores the day.",
- checked = strainTargetReport,
- onChange = {
- strainTargetReport = it
- NoopPrefs.setStrainTargetEnabled(context, it)
- },
- )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ StatusChip(
+ icon = Icons.Filled.Watch,
+ text = if (connected) "WHOOP connected" else "WHOOP offline",
+ positive = connected,
+ )
+ StatusChip(
+ icon = Icons.Filled.CheckCircle,
+ text = "$enabledApps apps",
+ positive = enabledApps > 0,
+ )
+ }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("Test your wrist", style = NoopType.body, color = Palette.textPrimary)
+ Text("Sends a short two-pulse test now.", style = NoopType.footnote, color = Palette.textTertiary)
+ }
+ ActionPill("Test buzz", Icons.Filled.GraphicEq, connected, onTest)
+ }
}
}
}
-// MARK: - Strap status (mirrors the three-state mapping from the Mac screen)
-
-private fun strapPillTitle(live: com.noop.ble.LiveState): String = when {
- live.connected -> "Strap connected"
- live.bonded -> "Strap idle"
- else -> "Strap not connected"
-}
-
-private fun strapPillTone(live: com.noop.ble.LiveState): StrandTone = when {
- live.connected -> StrandTone.Positive
- live.bonded -> StrandTone.Warning
- else -> StrandTone.Critical
-}
-
@Composable
-private fun CallsCard(
- masterEnabled: Boolean,
- callsEnabled: Boolean,
- phoneCallsEnabled: Boolean,
- voipCallsEnabled: Boolean,
+private fun CallsControlCard(
+ master: Boolean,
+ enabled: Boolean,
+ phoneEnabled: Boolean,
+ voipEnabled: Boolean,
pattern: BuzzPattern,
bonded: Boolean,
permissionDenied: Boolean,
- onCallsEnabled: (Boolean) -> Unit,
- onPhoneCallsEnabled: (Boolean) -> Unit,
- onVoipCallsEnabled: (Boolean) -> Unit,
+ onEnabled: (Boolean) -> Unit,
+ onPhone: (Boolean) -> Unit,
+ onVoip: (Boolean) -> Unit,
onPattern: (BuzzPattern) -> Unit,
onTest: () -> Unit,
) {
- val contentAlpha = if (masterEnabled) 1f else Palette.disabledOpacity
- AlertSection(
- icon = Icons.Filled.Call,
- title = uiString(R.string.l10n_notifications_settings_screen_calls_0a19b7e2),
- blurb = "Tap your wrist for incoming phone calls and strict best-effort VoIP calls.",
- ) {
- Column(modifier = Modifier.alphaIf(contentAlpha)) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(48.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
- Text(uiString(R.string.l10n_notifications_settings_screen_buzz_on_incoming_calls_625804a1), style = NoopType.body, color = Palette.textPrimary)
- Text(
- uiString(R.string.l10n_notifications_settings_screen_uses_the_same_quiet_hours_and_03badcac),
- style = NoopType.footnote,
- color = Palette.textTertiary,
- )
- }
- if (callsEnabled) {
- PatternMenu(pattern = pattern, enabled = masterEnabled, appName = "calls", onSelect = onPattern)
- TestIconButton(enabled = masterEnabled && bonded, appName = "calls", onClick = onTest)
- }
- NoopSwitch(
- checked = callsEnabled,
- onChange = onCallsEnabled,
- enabled = masterEnabled,
- label = uiString(R.string.l10n_notifications_settings_screen_buzz_on_incoming_calls_625804a1),
- )
+ SectionCard(Icons.Filled.Call, "Calls", "Phone calls deserve the most obvious wrist cue.") {
+ ToggleRow("Incoming calls", "Master switch for call haptics.", enabled, master, onEnabled)
+ if (enabled) {
+ DividerLine()
+ ToggleRow("Phone calls", "Native cellular calls. NOOP does not upload numbers or call logs.", phoneEnabled, master, onPhone)
+ if (permissionDenied) {
+ Text("Phone permission was denied. Turn it on in Android Settings to detect calls.", style = NoopType.footnote, color = Palette.statusCritical)
}
- if (callsEnabled) {
- RowDivider()
- FormToggleRow(
- label = uiString(R.string.l10n_notifications_settings_screen_phone_calls_b79420d9),
- help = "Needs Phone permission; NOOP never reads numbers or call logs.",
- checked = phoneCallsEnabled,
- enabled = masterEnabled,
- onChange = onPhoneCallsEnabled,
- )
- if (permissionDenied) {
- Text(
- uiString(R.string.l10n_notifications_settings_screen_phone_permission_was_denied_so_phone_db0ebdd8),
- style = NoopType.footnote,
- color = Palette.statusCritical,
- modifier = Modifier.padding(top = 2.dp, bottom = 8.dp),
- )
+ DividerLine()
+ ToggleRow("VoIP calls", "Best-effort detection for supported calling apps.", voipEnabled, master, onVoip)
+ DividerLine()
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("Call pattern", style = NoopType.body, color = Palette.textPrimary)
+ Text("Immediate buzz + finite reminders while ringing.", style = NoopType.footnote, color = Palette.textTertiary)
}
- RowDivider()
- FormToggleRow(
- label = uiString(R.string.l10n_notifications_settings_screen_voip_calls_96c5a102),
- help = "Detects call-style notifications from known calling apps.",
- checked = voipCallsEnabled,
- enabled = masterEnabled,
- onChange = onVoipCallsEnabled,
- )
+ PatternPicker(pattern, master, "call alerts", onPattern)
+ ActionPill("Test", Icons.Filled.GraphicEq, bonded && master, onTest)
}
}
}
}
-// MARK: - Delivery note (Notification Access requirement + deep link)
-
-@Composable
-private fun DeliveryNote() {
- val context = LocalContext.current
- val shape = RoundedCornerShape(10.dp)
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .clip(shape)
- .background(Palette.surfaceInset)
- .border(1.dp, Palette.accent.copy(alpha = 0.22f), shape)
- .padding(12.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Icon(
- Icons.Filled.Info,
- contentDescription = null,
- tint = Palette.accent,
- modifier = Modifier.size(16.dp),
- )
- Text(
- uiString(R.string.l10n_notifications_settings_screen_wrist_delivery_needs_notification_access_so_2a14e784) +
- "you. Nothing leaves this device. Your choices are saved now and apply " +
- "automatically once access is granted.",
- style = NoopType.footnote,
- color = Palette.textSecondary,
- )
- }
- Row(
- modifier = Modifier
- .clip(RoundedCornerShape(50))
- .clickable {
- runCatching {
- context.startActivity(
- Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
- .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
- )
- }
- }
- .padding(horizontal = 2.dp, vertical = 2.dp)
- .semantics { contentDescription = uiString(R.string.l10n_notifications_settings_screen_open_notification_access_settings_93fcd1bf) },
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- Icon(
- Icons.AutoMirrored.Filled.OpenInNew,
- contentDescription = null,
- tint = Palette.accent,
- modifier = Modifier.size(14.dp),
- )
- Text(uiString(R.string.l10n_notifications_settings_screen_open_notification_access_658fd30f), style = NoopType.caption, color = Palette.accent)
- }
- }
-}
-
-// MARK: - Category card (rows of apps, dimmed/disabled when master is off)
-
@Composable
-private fun CategoryCard(
- category: NotifCategory,
- apps: List,
- masterEnabled: Boolean,
- bonded: Boolean,
+private fun AppsControlCard(
+ master: Boolean,
enabledState: SnapshotStateMap,
patternState: SnapshotStateMap,
onToggle: (NotifApp, Boolean) -> Unit,
onPattern: (NotifApp, BuzzPattern) -> Unit,
onTest: (NotifApp) -> Unit,
) {
- val contentAlpha = if (masterEnabled) 1f else Palette.disabledOpacity
- AlertSection(icon = category.icon, title = category.title) {
- Column(modifier = Modifier.alphaIf(contentAlpha)) {
- apps.forEachIndexed { idx, app ->
- AppRow(
+ SectionCard(Icons.Filled.NotificationsActive, "Apps", "Choose exactly which notifications deserve your attention.") {
+ val grouped = notifCatalog.groupBy { it.category }
+ grouped.forEach { (category, apps) ->
+ Text(category.title, style = NoopType.caption, color = Palette.accent)
+ apps.forEachIndexed { index, app ->
+ AppAlertRow(
app = app,
enabled = enabledState[app.id] ?: false,
pattern = patternState[app.id] ?: app.category.defaultPattern,
- interactive = masterEnabled,
- bonded = bonded,
+ interactive = master,
onToggle = { onToggle(app, it) },
onPattern = { onPattern(app, it) },
onTest = { onTest(app) },
)
- if (idx < apps.size - 1) RowDivider()
+ if (index != apps.lastIndex) DividerLine()
}
+ if (category != grouped.keys.last()) Spacer(Modifier.size(10.dp))
}
}
}
@Composable
-private fun AppRow(
+private fun AppAlertRow(
app: NotifApp,
enabled: Boolean,
pattern: BuzzPattern,
interactive: Boolean,
- bonded: Boolean,
onToggle: (Boolean) -> Unit,
onPattern: (BuzzPattern) -> Unit,
onTest: () -> Unit,
@@ -736,298 +343,167 @@ private fun AppRow(
Row(
modifier = Modifier
.fillMaxWidth()
- .height(48.dp)
- // An enabled app reads as a selected row: a soft accentMuted wash behind it.
- .clip(RoundedCornerShape(10.dp))
- .then(if (enabled) Modifier.background(Palette.accentMuted) else Modifier)
- .padding(horizontal = 8.dp),
+ .clip(RoundedCornerShape(12.dp))
+ .background(if (enabled) Palette.accentMuted.copy(alpha = 0.45f) else Palette.surfaceInset.copy(alpha = 0.45f))
+ .padding(horizontal = 10.dp, vertical = 9.dp)
+ .alpha(if (interactive) 1f else Palette.disabledOpacity),
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
- // App glyph in a rounded inset tile (stand-in for the real macOS app icon).
Box(
- modifier = Modifier
- .size(34.dp)
- .clip(RoundedCornerShape(8.dp))
- .background(Palette.surfaceInset),
+ modifier = Modifier.size(34.dp).clip(RoundedCornerShape(9.dp)).background(Palette.surfaceRaised),
contentAlignment = Alignment.Center,
) {
Icon(app.glyph, contentDescription = null, tint = Palette.textSecondary, modifier = Modifier.size(18.dp))
}
-
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(app.name, style = NoopType.body, color = Palette.textPrimary)
- Text(
- if (enabled) "Buzzes your wrist" else "Off",
- style = NoopType.footnote,
- color = if (enabled) Palette.accent else Palette.textTertiary,
- )
+ Text(if (enabled) "Wrist alert enabled" else "Off", style = NoopType.footnote, color = if (enabled) Palette.accent else Palette.textTertiary)
}
-
if (enabled) {
- PatternMenu(
- pattern = pattern,
- enabled = interactive,
- appName = app.name,
- onSelect = onPattern,
- )
- TestIconButton(enabled = interactive && bonded, appName = app.name, onClick = onTest)
+ PatternPicker(pattern, interactive, app.name, onPattern)
+ ActionPill("Test", Icons.Filled.GraphicEq, interactive, onTest)
}
-
- NoopSwitch(
- checked = enabled,
- onChange = onToggle,
- enabled = interactive,
- label = uiString(R.string.l10n_notifications_settings_screen_app_name_wrist_alerts_dd3540fa, app.name),
- )
+ NoopSwitch(checked = enabled, onChange = onToggle, enabled = interactive, label = "${app.name} wrist alert")
}
}
-// MARK: - Pattern menu (DropdownMenu replacing the macOS Menu)
-
@Composable
-private fun PatternMenu(
- pattern: BuzzPattern,
- enabled: Boolean,
- appName: String,
- onSelect: (BuzzPattern) -> Unit,
+private fun NotificationBehaviourCard(
+ master: Boolean,
+ wornOnly: Boolean,
+ quiet: Boolean,
+ alarmTimer: Boolean,
+ allOther: Boolean,
+ onWornOnly: (Boolean) -> Unit,
+ onQuiet: (Boolean) -> Unit,
+ onAlarm: (Boolean) -> Unit,
+ onAllOther: (Boolean) -> Unit,
) {
- var expanded by remember { mutableStateOf(false) }
- val shape = RoundedCornerShape(50)
- Box {
- Row(
- modifier = Modifier
- .clip(shape)
- .background(Palette.surfaceInset)
- .border(1.dp, Palette.hairline, shape)
- .clickable(enabled = enabled) { expanded = true }
- .padding(horizontal = 10.dp, vertical = 5.dp)
- .semantics { contentDescription = uiString(R.string.l10n_notifications_settings_screen_buzz_pattern_for_appname_905a31bd, appName) },
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(5.dp),
- ) {
- Icon(
- Icons.Filled.GraphicEq,
- contentDescription = null,
- tint = Palette.textSecondary,
- modifier = Modifier.size(12.dp),
+ SectionCard(Icons.Filled.Tune, "Behaviour", "Rules that keep wrist alerts useful instead of noisy.") {
+ ToggleRow("Only when worn", "Don't buzz an unattended strap.", wornOnly, master, onWornOnly)
+ DividerLine()
+ ToggleRow("Quiet hours", "Mute all wrist alerts during your saved quiet window.", quiet, master, onQuiet)
+ DividerLine()
+ ToggleRow("Phone alarms & timers", "Buzz when another clock app posts an alarm notification.", alarmTimer, master, onAlarm)
+ DividerLine()
+ ToggleRow("Other apps", "Allow notifications from apps not listed above. This can be noisy.", allOther, master, onAllOther)
+ }
+}
+
+@Composable
+private fun AccessCard(context: Context) {
+ SectionCard(Icons.Filled.Settings, "Permissions & privacy", "NOOP needs Android notification access to see app events.") {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Filled.Info, contentDescription = null, tint = Palette.accent, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(10.dp))
+ Text(
+ "Notification contents stay on this phone. NOOP uses the posting app and event state to decide whether to buzz your WHOOP.",
+ style = NoopType.footnote,
+ color = Palette.textSecondary,
)
- Text(pattern.label, style = NoopType.caption, color = Palette.textSecondary)
}
- DropdownMenu(
- expanded = expanded,
- onDismissRequest = { expanded = false },
- modifier = Modifier.background(Palette.surfaceOverlay),
+ ActionPill(
+ "Open Notification Access",
+ Icons.Filled.OpenInNew,
+ true,
) {
- BuzzPattern.entries.forEach { p ->
- DropdownMenuItem(
- text = {
- Text(
- p.label,
- style = NoopType.body,
- color = if (p == pattern) Palette.accent else Palette.textPrimary,
- )
- },
- onClick = {
- onSelect(p)
- expanded = false
- },
- )
+ runCatching {
+ context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
}
}
-// MARK: - Test buttons
+@Composable
+private fun SectionCard(icon: ImageVector, title: String, subtitle: String, content: @Composable () -> Unit) {
+ NoopCard(padding = 18.dp, tint = Palette.hairline) {
+ Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Box(
+ modifier = Modifier.size(34.dp).clip(RoundedCornerShape(10.dp)).background(Palette.accentMuted),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(icon, contentDescription = null, tint = Palette.accent, modifier = Modifier.size(18.dp))
+ }
+ Column {
+ Text(title, style = NoopType.title2, color = Palette.textPrimary)
+ Text(subtitle, style = NoopType.footnote, color = Palette.textSecondary)
+ }
+ }
+ content()
+ }
+ }
+}
@Composable
-private fun TestIconButton(enabled: Boolean, appName: String, onClick: () -> Unit) {
- val shape = RoundedCornerShape(8.dp)
- val tint = if (enabled) Palette.accent else Palette.textTertiary
- Box(
- modifier = Modifier
- .size(28.dp)
- .clip(shape)
- .background(Palette.accent.copy(alpha = if (enabled) 0.12f else 0.04f))
- .border(1.dp, tint.copy(alpha = 0.30f), shape)
- .clickable(enabled = enabled, onClick = onClick)
- .semantics { contentDescription = uiString(R.string.l10n_notifications_settings_screen_test_appname_buzz_dbae5be3, appName) },
- contentAlignment = Alignment.Center,
- ) {
- Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = tint, modifier = Modifier.size(15.dp))
+private fun ToggleRow(label: String, help: String, checked: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(label, style = NoopType.body, color = Palette.textPrimary)
+ Text(help, style = NoopType.footnote, color = Palette.textTertiary)
+ }
+ Spacer(Modifier.width(12.dp))
+ NoopSwitch(checked, onChange, enabled, label)
}
}
@Composable
-private fun PillButton(label: String, icon: ImageVector, enabled: Boolean, onClick: () -> Unit) {
- val shape = RoundedCornerShape(50)
- val tint = if (enabled) Palette.accent else Palette.textTertiary
+private fun StatusChip(icon: ImageVector, text: String, positive: Boolean) {
+ val tint = if (positive) Palette.accent else Palette.textTertiary
Row(
modifier = Modifier
- .clip(shape)
- .background(Palette.accent.copy(alpha = if (enabled) 0.12f else 0.04f))
- .border(1.dp, tint.copy(alpha = 0.30f), shape)
- .clickable(enabled = enabled, onClick = onClick)
- .padding(horizontal = 12.dp, vertical = 6.dp),
+ .clip(RoundedCornerShape(50))
+ .background(Palette.surfaceInset)
+ .border(1.dp, tint.copy(alpha = 0.22f), RoundedCornerShape(50))
+ .padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
- Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(14.dp))
- Text(label, style = NoopType.caption, color = tint)
+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(13.dp))
+ Text(text, style = NoopType.caption, color = tint)
}
}
-// MARK: - Time chip (TimePickerDialog → HH:mm). Reused by the Automations smart-alarm time too.
-
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
-internal fun TimeChip(
- minutes: Int,
- accessibilityLabel: String,
- onPicked: (Int) -> Unit,
-) {
- var showPicker by remember { mutableStateOf(false) }
- val shape = RoundedCornerShape(50)
- val hour = minutes / 60
- val minute = minutes % 60
- Text(
- text = uiString(R.string.l10n_notifications_settings_screen_02d_02d_ce23a78c, hour, minute),
- style = NoopType.number(15f),
- color = Palette.accent,
+private fun ActionPill(label: String, icon: ImageVector, enabled: Boolean, onClick: () -> Unit) {
+ val tint = if (enabled) Palette.accent else Palette.textTertiary
+ Row(
modifier = Modifier
- .clip(shape)
- .background(Palette.surfaceInset)
- .border(1.dp, Palette.hairline, shape)
- .clickable { showPicker = true }
- .padding(horizontal = 12.dp, vertical = 6.dp)
- .semantics { contentDescription = accessibilityLabel },
- )
-
- if (showPicker) {
- // Material3 1.2.x has TimePicker + rememberTimePickerState but not a packaged
- // TimePickerDialog, so we wrap the picker in a plain Dialog ourselves.
- val state = rememberTimePickerState(
- initialHour = hour,
- initialMinute = minute,
- is24Hour = true,
- )
- Dialog(onDismissRequest = { showPicker = false }) {
- Column(
- modifier = Modifier
- .clip(RoundedCornerShape(20.dp))
- .background(Palette.surfaceOverlay)
- .border(1.dp, Palette.hairline, RoundedCornerShape(20.dp))
- .padding(20.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- Text(accessibilityLabel, style = NoopType.headline, color = Palette.textPrimary)
- TimePicker(
- state = state,
- colors = TimePickerDefaults.colors(
- clockDialColor = Palette.surfaceInset,
- clockDialSelectedContentColor = Palette.surfaceBase,
- clockDialUnselectedContentColor = Palette.textPrimary,
- selectorColor = Palette.accent,
- periodSelectorBorderColor = Palette.hairline,
- timeSelectorSelectedContainerColor = Palette.accentMuted,
- timeSelectorUnselectedContainerColor = Palette.surfaceInset,
- timeSelectorSelectedContentColor = Palette.accent,
- timeSelectorUnselectedContentColor = Palette.textPrimary,
- ),
- )
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
- ) {
- Text(
- uiString(R.string.l10n_notifications_settings_screen_cancel_77dfd213),
- style = NoopType.body,
- color = Palette.textSecondary,
- modifier = Modifier
- .clip(RoundedCornerShape(50))
- .clickable { showPicker = false }
- .padding(horizontal = 16.dp, vertical = 8.dp),
- )
- Text(
- uiString(R.string.l10n_notifications_settings_screen_set_448ab73b),
- style = NoopType.body,
- color = Palette.accent,
- modifier = Modifier
- .clip(RoundedCornerShape(50))
- .clickable {
- onPicked(state.hour * 60 + state.minute)
- showPicker = false
- }
- .padding(horizontal = 16.dp, vertical = 8.dp),
- )
- }
- }
- }
+ .clip(RoundedCornerShape(50))
+ .background(tint.copy(alpha = if (enabled) 0.12f else 0.04f))
+ .border(1.dp, tint.copy(alpha = 0.25f), RoundedCornerShape(50))
+ .clickable(enabled = enabled, onClick = onClick)
+ .padding(horizontal = 10.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ ) {
+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(13.dp))
+ Text(label, style = NoopType.caption, color = tint)
}
}
-// MARK: - Section card (icon + title header, optional blurb, content)
-
@Composable
-private fun AlertSection(
- icon: ImageVector,
- title: String,
- blurb: String? = null,
- overline: String = "Alerts",
- content: @Composable () -> Unit,
-) {
- NoopCard(padding = 20.dp, tint = Palette.accent) {
- Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
- Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
- Overline(overline)
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Icon(icon, contentDescription = null, tint = Palette.accent, modifier = Modifier.size(18.dp))
- Text(title, style = NoopType.title2, color = Palette.textPrimary)
- }
- }
- if (blurb != null) {
- Text(blurb, style = NoopType.subhead, color = Palette.textSecondary)
+private fun PatternPicker(pattern: BuzzPattern, enabled: Boolean, name: String, onSelect: (BuzzPattern) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+ Box {
+ ActionPill(pattern.label, Icons.Filled.GraphicEq, enabled) { expanded = true }
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ BuzzPattern.entries.forEach { option ->
+ DropdownMenuItem(
+ text = { Text(if (option == pattern) "✓ ${option.label}" else option.label, color = Palette.textPrimary) },
+ onClick = {
+ onSelect(option)
+ expanded = false
+ },
+ )
}
- content()
- }
- }
-}
-
-// MARK: - Label + help + switch row (mirrors FormToggleRow)
-
-@Composable
-private fun FormToggleRow(
- label: String,
- help: String,
- checked: Boolean,
- enabled: Boolean = true,
- onChange: (Boolean) -> Unit,
-) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(48.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
- Text(label, style = NoopType.body, color = Palette.textPrimary)
- Text(help, style = NoopType.footnote, color = Palette.textTertiary)
}
- Spacer(Modifier.width(16.dp))
- NoopSwitch(checked = checked, onChange = onChange, enabled = enabled, label = label)
}
}
-// MARK: - Shared bits
-
@Composable
-private fun NoopSwitch(
- checked: Boolean,
- onChange: (Boolean) -> Unit,
- enabled: Boolean = true,
- label: String,
-) {
+private fun NoopSwitch(checked: Boolean, onChange: (Boolean) -> Unit, enabled: Boolean, label: String) {
Switch(
checked = checked,
onCheckedChange = onChange,
@@ -1039,20 +515,10 @@ private fun NoopSwitch(
uncheckedTrackColor = Palette.surfaceInset,
uncheckedBorderColor = Palette.hairline,
),
- modifier = Modifier.semantics { contentDescription = label },
)
}
@Composable
-private fun RowDivider() {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(1.dp)
- .padding(vertical = 4.dp)
- .background(Palette.hairline),
- )
+private fun DividerLine() {
+ Box(modifier = Modifier.fillMaxWidth().size(height = 1.dp, width = 1.dp).background(Palette.hairline.copy(alpha = 0.65f)))
}
-
-/** Apply a uniform alpha to a subtree (dims disabled category content). */
-private fun Modifier.alphaIf(value: Float): Modifier = this.alpha(value)
diff --git a/android/app/src/main/java/com/noop/ui/TimeChip.kt b/android/app/src/main/java/com/noop/ui/TimeChip.kt
new file mode 100644
index 0000000000..0311df9bc2
--- /dev/null
+++ b/android/app/src/main/java/com/noop/ui/TimeChip.kt
@@ -0,0 +1,94 @@
+package com.noop.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Text
+import androidx.compose.material3.TimePicker
+import androidx.compose.material3.TimePickerDefaults
+import androidx.compose.material3.rememberTimePickerState
+import androidx.compose.runtime.Composable
+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.unit.dp
+import androidx.compose.ui.window.Dialog
+
+/** Shared 24-hour time selector used by Automations and other settings screens. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun TimeChip(
+ minutes: Int,
+ accessibilityLabel: String,
+ onPicked: (Int) -> Unit,
+) {
+ var showPicker by remember { mutableStateOf(false) }
+ val hour = (minutes / 60).coerceIn(0, 23)
+ val minute = (minutes % 60).coerceIn(0, 59)
+
+ Text(
+ text = "%02d:%02d".format(hour, minute),
+ style = NoopType.number(15f),
+ color = Palette.accent,
+ modifier = Modifier
+ .clip(RoundedCornerShape(50))
+ .background(Palette.surfaceInset)
+ .border(1.dp, Palette.hairline, RoundedCornerShape(50))
+ .clickable { showPicker = true }
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ )
+
+ if (showPicker) {
+ val state = rememberTimePickerState(initialHour = hour, initialMinute = minute, is24Hour = true)
+ Dialog(onDismissRequest = { showPicker = false }) {
+ Column(
+ modifier = Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(Palette.surfaceOverlay)
+ .padding(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Text(accessibilityLabel, style = NoopType.headline, color = Palette.textPrimary)
+ TimePicker(
+ state = state,
+ colors = TimePickerDefaults.colors(
+ clockDialColor = Palette.surfaceInset,
+ clockDialSelectedContentColor = Palette.surfaceBase,
+ clockDialUnselectedContentColor = Palette.textPrimary,
+ selectorColor = Palette.accent,
+ periodSelectorBorderColor = Palette.hairline,
+ timeSelectorSelectedContainerColor = Palette.accentMuted,
+ timeSelectorUnselectedContainerColor = Palette.surfaceInset,
+ timeSelectorSelectedContentColor = Palette.accent,
+ timeSelectorUnselectedContentColor = Palette.textPrimary,
+ ),
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
+ ) {
+ Text("Cancel", color = Palette.textSecondary, modifier = Modifier.clickable { showPicker = false }.padding(10.dp))
+ Text(
+ "Set",
+ color = Palette.accent,
+ modifier = Modifier.clickable {
+ onPicked(state.hour * 60 + state.minute)
+ showPicker = false
+ }.padding(10.dp),
+ )
+ }
+ }
+ }
+ }
+}
From ba06cd0aa4e0aa91c25bb0afe20141117ba44475 Mon Sep 17 00:00:00 2001
From: Binesh Ellupurayil Balachandran
<31845423+binesheb@users.noreply.github.com>
Date: Mon, 10 Aug 2026 21:52:20 +0530
Subject: [PATCH 03/10] Improve privacy-preserving VoIP call detection
Use Android CallStyle plus CATEGORY_CALL without inspecting notification content, and expand supported VoIP packages.
---
.../java/com/noop/notif/VoipCallClassifier.kt | 24 ++++++++++++-------
1 file changed, 15 insertions(+), 9 deletions(-)
diff --git a/android/app/src/main/java/com/noop/notif/VoipCallClassifier.kt b/android/app/src/main/java/com/noop/notif/VoipCallClassifier.kt
index 457e04b947..ba002db992 100644
--- a/android/app/src/main/java/com/noop/notif/VoipCallClassifier.kt
+++ b/android/app/src/main/java/com/noop/notif/VoipCallClassifier.kt
@@ -1,12 +1,15 @@
package com.noop.notif
import android.app.Notification
+import android.os.Build
/**
- * Strict VoIP call detection for NotificationListenerService.
+ * Strict, privacy-preserving VoIP call detection for NotificationListenerService.
*
- * The classifier intentionally uses only package name, category, and flags. It does not
- * inspect title, text, people, sender, or extras that may contain caller/message content.
+ * We intentionally inspect only package identity, notification category/flags, and the public
+ * Notification.CallStyle type. We never inspect title, text, people, caller names, or extras.
+ * Android documents CATEGORY_CALL as the category for incoming voice/video calls, while CallStyle
+ * is the platform's dedicated call notification style on API 31+.
*/
internal object VoipCallClassifier {
const val CATEGORY_CALL = "call"
@@ -20,6 +23,8 @@ internal object VoipCallClassifier {
"com.google.android.apps.tachyon",
"com.google.android.apps.meetings",
"com.facebook.orca",
+ "com.discord",
+ "com.instagram.android",
)
data class Metadata(
@@ -27,17 +32,17 @@ internal object VoipCallClassifier {
val isOngoing: Boolean,
val isForegroundService: Boolean,
val isGroupSummary: Boolean,
+ val isCallStyle: Boolean,
)
- fun isKnownVoipPackage(packageName: String): Boolean =
- packageName in knownVoipPackages
+ fun isKnownVoipPackage(packageName: String): Boolean = packageName in knownVoipPackages
fun isIncomingCallNotification(packageName: String, metadata: Metadata): Boolean {
if (!isKnownVoipPackage(packageName)) return false
- if (metadata.isForegroundService || metadata.isGroupSummary) return false
- // Do not read CallStyle extras here: this trades VoIP recall for the documented
- // wrist-alert privacy contract that notification extras are not inspected.
- return metadata.category == CATEGORY_CALL && !metadata.isOngoing
+ if (metadata.isForegroundService || metadata.isGroupSummary || metadata.isOngoing) return false
+ // Prefer Android's explicit CallStyle signal when available; otherwise fall back to the
+ // documented CATEGORY_CALL contract used by older Android and third-party VoIP apps.
+ return metadata.isCallStyle || metadata.category == CATEGORY_CALL
}
fun metadataOf(notification: Notification, isOngoing: Boolean): Metadata =
@@ -46,5 +51,6 @@ internal object VoipCallClassifier {
isOngoing = isOngoing,
isForegroundService = (notification.flags and Notification.FLAG_FOREGROUND_SERVICE) != 0,
isGroupSummary = (notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0,
+ isCallStyle = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && notification.style is Notification.CallStyle,
)
}
From 473b6e8333ed84031f0dc9d717d49ce45dd49dcb Mon Sep 17 00:00:00 2001
From: Binesh Ellupurayil Balachandran
<31845423+binesheb@users.noreply.github.com>
Date: Tue, 11 Aug 2026 12:30:09 +0530
Subject: [PATCH 04/10] Update README.md
---
README.md | 6 ------
1 file changed, 6 deletions(-)
diff --git a/README.md b/README.md
index ae244632e1..f74fb69e02 100644
--- a/README.md
+++ b/README.md
@@ -616,9 +616,3 @@ protocol alongside us — this project is built on it.

-
-### Star history
-
-If NOOP's useful to you, a ⭐ genuinely helps it reach more WHOOP users — and it's the single best free way to support the project.
-
-[](https://star-history.com/#ryanbr/noop&Date)
From 8ddfa2b81ce701ab59a48a539503974034abfd3b Mon Sep 17 00:00:00 2001
From: Binesh Ellupurayil Balachandran
<31845423+binesheb@users.noreply.github.com>
Date: Tue, 11 Aug 2026 12:35:50 +0530
Subject: [PATCH 05/10] NOOP Next: reliable WHOOP call haptics
Harden phone and VoIP call haptics with encrypted-link gating, shared scheduling, finite reminders, reconnect-safe delivery, watchdog recovery, tests, and updated notification UX documentation.
---
.../com/noop/notif/CallAlertController.kt | 48 ++++-----
.../java/com/noop/notif/CallAlertPolicy.kt | 18 ++--
docs/NOOP_NEXT.md | 102 +++++++-----------
3 files changed, 68 insertions(+), 100 deletions(-)
diff --git a/android/app/src/main/java/com/noop/notif/CallAlertController.kt b/android/app/src/main/java/com/noop/notif/CallAlertController.kt
index d5532ec530..ef8e8a4595 100644
--- a/android/app/src/main/java/com/noop/notif/CallAlertController.kt
+++ b/android/app/src/main/java/com/noop/notif/CallAlertController.kt
@@ -12,18 +12,13 @@ internal enum class CallAlertSource {
}
/**
- * Shared call-buzz coordinator for native phone state and strict VoIP notifications.
+ * One local scheduler for all active calls.
*
- * This is intentionally a small state machine: phone/VoIP sources acquire tokens, while one
- * scheduler owns the physical WHOOP actuator. That prevents two simultaneous call sources from
- * doubling the haptic traffic.
+ * Phone and VoIP notifications can arrive from different Android callbacks. They therefore share
+ * one token set and one haptic scheduler so an overlapping call cannot create two independent
+ * reminder loops. No call number, contact, or notification body is retained here.
*/
internal object CallAlertController {
- /**
- * Hard ceiling on one call cycle. A stop event is not guaranteed — PHONE_STATE=IDLE can be
- * dropped and a VoIP notification can disappear without a removal callback. Five minutes is
- * long enough for legitimate calls while still self-healing a leaked token.
- */
private const val MAX_RING_WINDOW_MS = 5 * 60_000L
private val handler = Handler(Looper.getMainLooper())
@@ -35,23 +30,24 @@ internal object CallAlertController {
private val repeatRunnable = object : Runnable {
override fun run() {
- val ctx = appContext ?: return
- maybeBuzz(ctx)
+ appContext?.let(::maybeBuzz)
}
}
- /** Self-heal a leaked source if Android never delivers its stop event. */
+ /** Self-heals if Android misses a call-ended or notification-removed callback. */
private val maxRingRunnable = Runnable { stopAll() }
+ @Synchronized
fun start(context: Context, source: CallAlertSource, key: String = source.name): Boolean {
if (!sourceEnabled(context, source)) return false
+
appContext = context.applicationContext
val token = "${source.name}:$key"
val wasInactive = activeTokens.isEmpty()
activeTokens.add(token)
- // Re-arm the watchdog whenever the source reports life. This prevents a dropped stop
- // event from permanently wedging the next call cycle.
+ // Re-arm the watchdog on every source update. The watchdog only clears the call state;
+ // it never sends a haptic itself.
handler.removeCallbacks(maxRingRunnable)
handler.postDelayed(maxRingRunnable, MAX_RING_WINDOW_MS)
@@ -64,16 +60,19 @@ internal object CallAlertController {
return true
}
+ @Synchronized
fun stop(source: CallAlertSource, key: String = source.name) {
activeTokens.remove("${source.name}:$key")
if (activeTokens.isEmpty()) resetLoop()
}
+ @Synchronized
fun stopSource(source: CallAlertSource) {
activeTokens.removeAll { it.startsWith("${source.name}:") }
if (activeTokens.isEmpty()) resetLoop()
}
+ @Synchronized
fun stopAll() {
activeTokens.clear()
resetLoop()
@@ -86,19 +85,20 @@ internal object CallAlertController {
val now = System.currentTimeMillis()
if (!policy.shouldBuzz(true, buzzCount, lastBuzzAtMs, now)) return
- // Do not consume a call-alert slot while the strap is temporarily disconnected. The
- // existing connection service can recover the BLE link, and the next policy tick will
- // retry the same call instead of silently losing the alert.
- if (!deliveryAllowed(context)) {
+ val ble = (context.applicationContext as? NoopApplication)?.ble ?: run {
scheduleNext()
return
}
- val ble = (context.applicationContext as? NoopApplication)?.ble ?: run {
+ // A live-HR shortcut can report `bonded` without having the encrypted WHOOP command link.
+ // Haptics require the genuine encrypted bond, otherwise the command is silently lost.
+ if (!deliveryAllowed(context, ble)) {
scheduleNext()
return
}
+ // Keep all hardware-specific haptic encoding inside WhoopBleClient.buzz(). The call layer
+ // must never duplicate WHOOP 4 vs 5/MG opcodes or packet framing.
ble.buzz(NotifPrefs.callLoops(context))
buzzCount += 1
lastBuzzAtMs = now
@@ -139,16 +139,16 @@ internal object CallAlertController {
}
}
- private fun deliveryAllowed(context: Context): Boolean {
+ private fun deliveryAllowed(
+ context: Context,
+ ble: com.noop.ble.WhoopBleClient,
+ ): Boolean {
if (!NotifPrefs.getBool(context, NotifPrefs.MASTER, false)) return false
if (!NotifPrefs.getBool(context, NotifPrefs.CALLS_MASTER, false)) return false
if (NotifPrefs.inQuietHours(context)) return false
- val ble = (context.applicationContext as? NoopApplication)?.ble ?: return false
val state = ble.state.value
- // `bonded` can remain true after a transient GATT disconnect. A haptic command sent in
- // that window would be lost but would still count as a buzz, so require an active link.
- if (!state.connected || !state.bonded) return false
+ if (!state.connected || !state.encryptedBond) return false
if (NotifPrefs.getBool(context, NotifPrefs.WORN, true) && !state.worn) return false
return true
}
diff --git a/android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt b/android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt
index 07a744a899..a54ffdb7de 100644
--- a/android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt
+++ b/android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt
@@ -1,15 +1,6 @@
package com.noop.notif
-/**
- * Pure policy for repeated call buzzes.
- *
- * The controller owns Android scheduling and BLE delivery. Keeping the cadence here makes
- * the behaviour deterministic and unit-testable.
- *
- * Defaults are deliberately conservative: an incoming call gets an immediate buzz, followed
- * by a small number of reminders. We do not buzz indefinitely because the WHOOP motor is a
- * wearable haptic actuator and a never-ending loop would be intrusive and waste battery.
- */
+/** Pure, deterministic cadence rules for incoming-call haptics. */
internal data class CallAlertPolicy(
val repeatIntervalMs: Long = 6_000L,
val maxBuzzes: Int = 6,
@@ -19,7 +10,12 @@ internal data class CallAlertPolicy(
require(maxBuzzes >= 1) { "maxBuzzes must be at least 1" }
}
- fun shouldBuzz(active: Boolean, buzzCount: Int, lastBuzzAtMs: Long?, nowMs: Long): Boolean {
+ fun shouldBuzz(
+ active: Boolean,
+ buzzCount: Int,
+ lastBuzzAtMs: Long?,
+ nowMs: Long,
+ ): Boolean {
if (!active || buzzCount >= maxBuzzes) return false
return lastBuzzAtMs == null || nowMs - lastBuzzAtMs >= repeatIntervalMs
}
diff --git a/docs/NOOP_NEXT.md b/docs/NOOP_NEXT.md
index b17e747395..fc31d52f96 100644
--- a/docs/NOOP_NEXT.md
+++ b/docs/NOOP_NEXT.md
@@ -1,79 +1,51 @@
-# NOOP Next — product and engineering direction
+# NOOP Next — call haptics and notification UX
-NOOP should evolve as a local-first WHOOP companion rather than becoming a second cloud service.
-The guiding rule is: **phone events → local policy → WHOOP BLE haptic/data path**.
+NOOP should feel like a focused wearable companion: phone events are detected locally, evaluated by a small policy engine, and delivered to the WHOOP through the existing BLE transport.
-## vNext goals
+## Call-alert behaviour
-### 1. Call alerts that feel native
+- Immediate haptic when an enabled native phone or VoIP call begins.
+- Repeat every 6 seconds while the call remains active.
+- Maximum 6 haptic deliveries per call cycle.
+- Duplicate ringing events are idempotent.
+- A disconnected WHOOP does not consume a delivery slot; the alert retries after reconnection.
+- A five-minute watchdog clears a leaked active-call token if Android misses an end/removal callback.
+- Respect notification master, call master, source switches, quiet hours, and wear gating.
+- GSM phone calls remain on the native phone-state path; app notifications are handled by NotificationListenerService.
+- Haptic encoding remains inside `WhoopBleClient.buzz()`, allowing WHOOP 4.0 and WHOOP 5/MG transports to stay hardware-specific.
-- Native phone calls are the highest-priority alert source.
-- VoIP calls remain a separate source so a Teams/WhatsApp/Zoom call cannot accidentally enable native phone-call permission.
-- The call state machine must be idempotent: duplicate `RINGING` events do not multiply buzzes.
-- A disconnected strap must not consume a buzz slot. The connection service gets time to recover, then the same alert is retried.
-- A missed `IDLE`/notification-removal event must self-heal with a watchdog.
-- Default cadence: immediate buzz, then up to 5 reminders at 6-second intervals. This is intentionally finite.
-- Respect NOOP master notifications, call master, quiet hours, and wear-gating before sending a haptic.
+## Current UI
-### 2. WHOOP haptics
+The Android notification screen already uses the vNext control-centre layout:
-Use the existing, hardware-verified haptic path instead of inventing a new BLE command.
+1. Wrist-alert readiness and connection status.
+2. Calls with independent Phone / VoIP controls.
+3. Physical Test Buzz action.
+4. App alerts grouped by category with per-app patterns.
+5. Behaviour rules: wear gating, quiet hours, alarms/timers, other apps.
+6. Permissions and privacy diagnostics.
-WHOOP 4.0 uses `RUN_HAPTICS_PATTERN` (command 79) with the payload
-`[patternId, loops, 0, 0, 0]`; NOOP already uses pattern 2 for its graduated alarm-style buzz.
-WHOOP 5/MG has a separately remapped haptic path in the BLE client and must retain its family gate.
+The important path is deliberately visible: **phone event → NOOP detects → policy allows → encrypted WHOOP link → haptic delivered**.
-Do not send destructive/firmware commands from notification code.
+## WHOOP 5/MG research
-### 3. Better UI
+Current reverse-engineering work indicates that WHOOP 4.0 and WHOOP 5/MG do not use the same haptic opcode. WHOOP 4.0 uses `RUN_HAPTICS_PATTERN` (79), while the current NOOP research notes identify the 5/MG "maverick" haptic command as `0x13`. Keep that mapping inside the BLE client and never duplicate protocol bytes in the notification layer.
-The Notifications screen should become an event-control center instead of a long settings list.
+The Android notification code gates delivery on `LiveState.encryptedBond`, not merely the looser `bonded` signal. This prevents a 5/MG live-HR-only connection from being treated as a command-capable link.
-Proposed hierarchy:
+## Reliability acceptance tests
-1. **Wrist Alerts** — one master state and live delivery status.
-2. **Calls** — one large card with Phone Calls and VoIP Calls as independent switches.
-3. **Test Buzz** — one-tap physical test with the current pattern.
-4. **Apps** — grouped apps with search, enabled state, and pattern preview.
-5. **Quiet Hours** — a single visual time-range control.
-6. **Diagnostics** — last event, last delivery, connection state, and permission state.
+1. Incoming GSM call produces an immediate WHOOP buzz.
+2. A ringing call produces reminders at the finite cadence.
+3. Ending the call stops reminders immediately.
+4. Duplicate RINGING events never create a haptic storm.
+5. Disconnect/reconnect during ringing does not lose a delivery slot.
+6. Quiet hours suppress call haptics.
+7. Wear gating suppresses haptics when the strap is not worn.
+8. A missed stop callback self-heals after the watchdog.
+9. Simultaneous GSM and VoIP sources share one scheduler.
+10. WHOOP 4.0 and WHOOP 5/MG continue using their existing hardware-specific BLE haptic implementations.
-The call card should make the important path obvious:
+## Privacy
-`Phone rings → NOOP detects it → WHOOP connected → 3-pulse buzz`
-
-If any step is unavailable, show the exact blocker and a single action to fix it.
-
-### 4. Notification reliability
-
-Android's `NotificationListenerService` is the correct local mechanism for app notifications.
-It receives notification posted/removed callbacks and requires the user to grant Notification Access.
-Native phone calls should remain on the phone-state path rather than depending on notification text.
-
-### 5. Privacy
-
-- No notification contents leave the device.
-- No phone numbers are uploaded.
-- No cloud relay is required for call haptics.
-- Store only the minimum state required to make the local event machine reliable.
-
-## Acceptance tests for call haptics
-
-1. Incoming GSM call → WHOOP buzzes immediately.
-2. Call remains ringing → reminders occur every 6 seconds, up to the configured finite limit.
-3. WHOOP disconnects while ringing → no buzz slot is lost; delivery resumes after reconnection.
-4. WHOOP is not worn and wear-gating is enabled → no buzz.
-5. Quiet hours are active → no buzz.
-6. Call ends → the repeat loop stops immediately.
-7. Duplicate `RINGING` broadcasts → no duplicate haptic storm.
-8. A missed stop event → watchdog clears the token and the next call can alert.
-9. VoIP and GSM calls overlapping → one shared haptic scheduler, not two independent loops.
-10. WHOOP 4.0 and WHOOP 5/MG use their existing family-specific haptic transport.
-
-## Research references
-
-- Android `NotificationListenerService`: https://developer.android.com/reference/android/service/notification/NotificationListenerService
-- NOOP Android protocol notes: https://github.com/ryanbr/noop/blob/main/docs/ANDROID.md
-- WHOOP BLE reverse-engineering notes: https://www.rusheelraj.com/blog/whoop/
-
-These references are used for architecture/protocol validation; the implementation remains in NOOP's local-first codebase.
+Notification text, phone numbers, and call logs remain on-device. The call-alert path requires no cloud relay.
From 975eda61f83fe065509ca62319dd7e59a1e9db0d Mon Sep 17 00:00:00 2001
From: Binesh Ellupurayil Balachandran
<31845423+binesheb@users.noreply.github.com>
Date: Tue, 11 Aug 2026 12:46:09 +0530
Subject: [PATCH 06/10] Polish notification center and diagnostics
---
.../noop/ui/NotificationsSettingsScreen.kt | 216 ++++++++++++++----
1 file changed, 171 insertions(+), 45 deletions(-)
diff --git a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
index 3e6a4afd68..4918d2c338 100644
--- a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
@@ -1,6 +1,7 @@
package com.noop.ui
import android.Manifest
+import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@@ -29,12 +30,14 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Watch
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
@@ -60,9 +63,8 @@ import com.noop.notif.CallAlertSource
/**
* NOOP Next notification control centre.
*
- * The previous screen mixed every setting into a long list. This version puts the important
- * path first: enable wrist alerts, verify the strap, configure calls, then configure apps.
- * Existing preference keys are intentionally preserved so upgrades don't reset the user's choices.
+ * The screen is intentionally event-first: readiness, calls, apps, behaviour, then access and
+ * diagnostics. Existing preference keys are preserved so upgrades never reset user choices.
*/
@Composable
fun NotificationsSettingsScreen(vm: AppViewModel) {
@@ -79,6 +81,7 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
var allOther by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.ALL_OTHER, false)) }
var callPattern by remember { mutableStateOf(NotifPrefs.callPattern(context)) }
var permissionDenied by remember { mutableStateOf(false) }
+ var appQuery by remember { mutableStateOf("") }
val enabledState: SnapshotStateMap = remember {
mutableStateMapOf().apply {
@@ -100,7 +103,8 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
}
val enabledApps = enabledState.values.count { it }
- val deliveryReady = master && live.connected && (!wornOnly || live.worn) && !quiet
+ val deliveryReady = master && live.connected && live.encryptedBond && (!wornOnly || live.worn) && !quiet
+ val notificationAccess = notificationAccessGranted(context)
ScreenScaffold(
title = "Notifications",
@@ -109,6 +113,7 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
NotificationHero(
enabled = master,
connected = live.connected,
+ encryptedBond = live.encryptedBond,
worn = live.worn,
ready = deliveryReady,
enabledApps = enabledApps,
@@ -126,7 +131,7 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
phoneEnabled = phoneCalls,
voipEnabled = voipCalls,
pattern = callPattern,
- bonded = live.bonded,
+ commandReady = live.connected && live.encryptedBond,
permissionDenied = permissionDenied,
onEnabled = {
calls = it
@@ -167,6 +172,8 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
AppsControlCard(
master = master,
+ query = appQuery,
+ onQueryChange = { appQuery = it },
enabledState = enabledState,
patternState = patternState,
onToggle = { app, value ->
@@ -204,7 +211,15 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
},
)
- AccessCard(context = context)
+ AccessCard(context = context, notificationAccess = notificationAccess)
+ DiagnosticsCard(
+ enabled = master,
+ connected = live.connected,
+ encryptedBond = live.encryptedBond,
+ worn = live.worn,
+ notificationAccess = notificationAccess,
+ callsEnabled = calls && (phoneCalls || voipCalls),
+ )
}
}
@@ -212,6 +227,7 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
private fun NotificationHero(
enabled: Boolean,
connected: Boolean,
+ encryptedBond: Boolean,
worn: Boolean,
ready: Boolean,
enabledApps: Int,
@@ -228,6 +244,7 @@ private fun NotificationHero(
when {
!enabled -> "Off — your WHOOP will stay quiet."
!connected -> "Waiting for your WHOOP to connect."
+ !encryptedBond -> "Connected, but the secure command link is not ready."
!worn -> "Strap connected, but not currently worn."
else -> "Ready — important events can reach your wrist."
},
@@ -246,6 +263,11 @@ private fun NotificationHero(
)
StatusChip(
icon = Icons.Filled.CheckCircle,
+ text = if (encryptedBond) "Secure link" else "Link not ready",
+ positive = encryptedBond,
+ )
+ StatusChip(
+ icon = Icons.Filled.NotificationsActive,
text = "$enabledApps apps",
positive = enabledApps > 0,
)
@@ -256,7 +278,7 @@ private fun NotificationHero(
Text("Test your wrist", style = NoopType.body, color = Palette.textPrimary)
Text("Sends a short two-pulse test now.", style = NoopType.footnote, color = Palette.textTertiary)
}
- ActionPill("Test buzz", Icons.Filled.GraphicEq, connected, onTest)
+ ActionPill("Test buzz", Icons.Filled.GraphicEq, connected && encryptedBond, onTest)
}
}
}
@@ -269,7 +291,7 @@ private fun CallsControlCard(
phoneEnabled: Boolean,
voipEnabled: Boolean,
pattern: BuzzPattern,
- bonded: Boolean,
+ commandReady: Boolean,
permissionDenied: Boolean,
onEnabled: (Boolean) -> Unit,
onPhone: (Boolean) -> Unit,
@@ -289,12 +311,12 @@ private fun CallsControlCard(
ToggleRow("VoIP calls", "Best-effort detection for supported calling apps.", voipEnabled, master, onVoip)
DividerLine()
Row(verticalAlignment = Alignment.CenterVertically) {
- Column(modifier = Modifier.weight(1f)) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Call pattern", style = NoopType.body, color = Palette.textPrimary)
Text("Immediate buzz + finite reminders while ringing.", style = NoopType.footnote, color = Palette.textTertiary)
}
PatternPicker(pattern, master, "call alerts", onPattern)
- ActionPill("Test", Icons.Filled.GraphicEq, bonded && master, onTest)
+ ActionPill("Test", Icons.Filled.GraphicEq, commandReady && master, onTest)
}
}
}
@@ -303,29 +325,53 @@ private fun CallsControlCard(
@Composable
private fun AppsControlCard(
master: Boolean,
+ query: String,
+ onQueryChange: (String) -> Unit,
enabledState: SnapshotStateMap,
patternState: SnapshotStateMap,
onToggle: (NotifApp, Boolean) -> Unit,
onPattern: (NotifApp, BuzzPattern) -> Unit,
onTest: (NotifApp) -> Unit,
) {
+ val filtered = remember(query) {
+ val normalized = query.trim().lowercase()
+ if (normalized.isEmpty()) notifCatalog
+ else notifCatalog.filter { app ->
+ app.name.lowercase().contains(normalized) || app.id.lowercase().contains(normalized)
+ }
+ }
+
SectionCard(Icons.Filled.NotificationsActive, "Apps", "Choose exactly which notifications deserve your attention.") {
- val grouped = notifCatalog.groupBy { it.category }
- grouped.forEach { (category, apps) ->
- Text(category.title, style = NoopType.caption, color = Palette.accent)
- apps.forEachIndexed { index, app ->
- AppAlertRow(
- app = app,
- enabled = enabledState[app.id] ?: false,
- pattern = patternState[app.id] ?: app.category.defaultPattern,
- interactive = master,
- onToggle = { onToggle(app, it) },
- onPattern = { onPattern(app, it) },
- onTest = { onTest(app) },
- )
- if (index != apps.lastIndex) DividerLine()
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
+ placeholder = { Text("Search apps") },
+ shape = RoundedCornerShape(12.dp),
+ )
+
+ if (filtered.isEmpty()) {
+ Text("No supported app matches this search.", style = NoopType.footnote, color = Palette.textTertiary)
+ } else {
+ val grouped = filtered.groupBy { it.category }
+ grouped.forEach { (category, apps) ->
+ Text(category.title, style = NoopType.caption, color = Palette.accent)
+ apps.forEachIndexed { index, app ->
+ AppAlertRow(
+ app = app,
+ enabled = enabledState[app.id] ?: false,
+ pattern = patternState[app.id] ?: app.category.defaultPattern,
+ interactive = master,
+ onToggle = { onToggle(app, it) },
+ onPattern = { onPattern(app, it) },
+ onTest = { onTest(app) },
+ )
+ if (index != apps.lastIndex) DividerLine()
+ }
+ if (category != grouped.keys.last()) Spacer(Modifier.size(10.dp))
}
- if (category != grouped.keys.last()) Spacer(Modifier.size(10.dp))
}
}
}
@@ -392,22 +438,32 @@ private fun NotificationBehaviourCard(
}
@Composable
-private fun AccessCard(context: Context) {
+private fun AccessCard(context: Context, notificationAccess: Boolean) {
SectionCard(Icons.Filled.Settings, "Permissions & privacy", "NOOP needs Android notification access to see app events.") {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(Icons.Filled.Info, contentDescription = null, tint = Palette.accent, modifier = Modifier.size(18.dp))
- Spacer(Modifier.width(10.dp))
- Text(
- "Notification contents stay on this phone. NOOP uses the posting app and event state to decide whether to buzz your WHOOP.",
- style = NoopType.footnote,
- color = Palette.textSecondary,
- )
- }
- ActionPill(
- "Open Notification Access",
- Icons.Filled.OpenInNew,
- true,
- ) {
+ StatusRow(
+ icon = Icons.Filled.NotificationsActive,
+ title = "Notification access",
+ detail = if (notificationAccess) "Enabled — app events can be received locally." else "Required for app and VoIP notification detection.",
+ positive = notificationAccess,
+ )
+ Spacer(Modifier.size(6.dp))
+ StatusRow(
+ icon = Icons.Filled.Phone,
+ title = "Phone state permission",
+ detail = if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
+ "Enabled — native call state can be detected."
+ } else {
+ "Required only for native cellular call detection."
+ },
+ positive = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED,
+ )
+ DividerLine()
+ Text(
+ "Notification contents stay on this phone. NOOP uses the posting app and event state to decide whether to buzz your WHOOP.",
+ style = NoopType.footnote,
+ color = Palette.textSecondary,
+ )
+ ActionPill("Open Notification Access", Icons.Filled.OpenInNew, true) {
runCatching {
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
@@ -415,6 +471,81 @@ private fun AccessCard(context: Context) {
}
}
+@Composable
+private fun DiagnosticsCard(
+ enabled: Boolean,
+ connected: Boolean,
+ encryptedBond: Boolean,
+ worn: Boolean,
+ notificationAccess: Boolean,
+ callsEnabled: Boolean,
+) {
+ val ready = enabled && connected && encryptedBond && notificationAccess && (!NotifPrefs.getBool(LocalContext.current, NotifPrefs.WORN, true) || worn)
+ SectionCard(Icons.Filled.GraphicEq, "Delivery diagnostics", "A quick explanation of why an alert can or cannot reach your wrist.") {
+ StatusRow(
+ icon = Icons.Filled.Watch,
+ title = "WHOOP connection",
+ detail = if (connected) "Connected" else "Disconnected — connect your WHOOP first.",
+ positive = connected,
+ )
+ StatusRow(
+ icon = Icons.Filled.CheckCircle,
+ title = "Secure command link",
+ detail = if (encryptedBond) "Ready for haptic commands" else "Not ready — haptics will be held.",
+ positive = encryptedBond,
+ )
+ StatusRow(
+ icon = Icons.Filled.NotificationsActive,
+ title = "Notification listener",
+ detail = if (notificationAccess) "Ready for app events" else "Disabled — app alerts and VoIP detection are unavailable.",
+ positive = notificationAccess,
+ )
+ StatusRow(
+ icon = Icons.Filled.Call,
+ title = "Call alerts",
+ detail = if (callsEnabled) "Enabled" else "Disabled",
+ positive = callsEnabled,
+ )
+ DividerLine()
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("Overall readiness", style = NoopType.body, color = Palette.textPrimary)
+ Text(
+ if (ready) "Everything required for local wrist delivery is ready."
+ else "One or more prerequisites need attention.",
+ style = NoopType.footnote,
+ color = Palette.textTertiary,
+ )
+ }
+ StatusChip(Icons.Filled.CheckCircle, if (ready) "READY" else "CHECK", ready)
+ }
+ }
+}
+
+@Composable
+private fun StatusRow(icon: ImageVector, title: String, detail: String, positive: Boolean) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Icon(icon, contentDescription = null, tint = if (positive) Palette.accent else Palette.textTertiary, modifier = Modifier.size(18.dp))
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(title, style = NoopType.body, color = Palette.textPrimary)
+ Text(detail, style = NoopType.footnote, color = Palette.textTertiary)
+ }
+ StatusChip(Icons.Filled.CheckCircle, if (positive) "OK" else "WAIT", positive)
+ }
+}
+
+private fun notificationAccessGranted(context: Context): Boolean {
+ val enabled = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners") ?: return false
+ val component = ComponentName(context, NoopNotificationListenerService::class.java)
+ return enabled.split(":").any { raw ->
+ runCatching { ComponentName.unflattenFromString(raw) == component }.getOrDefault(false)
+ }
+}
+
@Composable
private fun SectionCard(icon: ImageVector, title: String, subtitle: String, content: @Composable () -> Unit) {
NoopCard(padding = 18.dp, tint = Palette.hairline) {
@@ -517,8 +648,3 @@ private fun NoopSwitch(checked: Boolean, onChange: (Boolean) -> Unit, enabled: B
),
)
}
-
-@Composable
-private fun DividerLine() {
- Box(modifier = Modifier.fillMaxWidth().size(height = 1.dp, width = 1.dp).background(Palette.hairline.copy(alpha = 0.65f)))
-}
From c86f213018f9b65cdae4cc232ce16fda52ca0be4 Mon Sep 17 00:00:00 2001
From: Binesh Ellupurayil Balachandran
<31845423+binesheb@users.noreply.github.com>
Date: Tue, 11 Aug 2026 12:48:02 +0530
Subject: [PATCH 07/10] Polish notification center and diagnostics
---
.../noop/ui/NotificationsSettingsScreen.kt | 438 ++++--------------
1 file changed, 82 insertions(+), 356 deletions(-)
diff --git a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
index 4918d2c338..8a4615a3b2 100644
--- a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
@@ -1,7 +1,6 @@
package com.noop.ui
import android.Manifest
-import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@@ -22,11 +21,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Alarm
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.GraphicEq
-import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Phone
@@ -51,6 +48,7 @@ import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.background
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@@ -60,12 +58,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.noop.notif.CallAlertController
import com.noop.notif.CallAlertSource
-/**
- * NOOP Next notification control centre.
- *
- * The screen is intentionally event-first: readiness, calls, apps, behaviour, then access and
- * diagnostics. Existing preference keys are preserved so upgrades never reset user choices.
- */
@Composable
fun NotificationsSettingsScreen(vm: AppViewModel) {
val context = LocalContext.current
@@ -106,24 +98,12 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
val deliveryReady = master && live.connected && live.encryptedBond && (!wornOnly || live.worn) && !quiet
val notificationAccess = notificationAccessGranted(context)
- ScreenScaffold(
- title = "Notifications",
- subtitle = "Make the information you need reach your wrist.",
- ) {
- NotificationHero(
- enabled = master,
- connected = live.connected,
- encryptedBond = live.encryptedBond,
- worn = live.worn,
- ready = deliveryReady,
- enabledApps = enabledApps,
- onMasterChange = {
- master = it
- NotifPrefs.setBool(context, NotifPrefs.MASTER, it)
- if (!it) CallAlertController.stopAll()
- },
- onTest = { vm.buzz(loops = 2) },
- )
+ ScreenScaffold(title = "Notifications", subtitle = "Make the information you need reach your wrist.") {
+ NotificationHero(master, live.connected, live.encryptedBond, live.worn, deliveryReady, enabledApps, {
+ master = it
+ NotifPrefs.setBool(context, NotifPrefs.MASTER, it)
+ if (!it) CallAlertController.stopAll()
+ }) { vm.buzz(loops = 2) }
CallsControlCard(
master = master,
@@ -144,18 +124,12 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
permissionDenied = false
NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, false)
CallAlertController.stopSource(CallAlertSource.PHONE)
+ } else if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
+ phoneCalls = true
+ permissionDenied = false
+ NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, true)
} else {
- val granted = ContextCompat.checkSelfPermission(
- context,
- Manifest.permission.READ_PHONE_STATE,
- ) == PackageManager.PERMISSION_GRANTED
- if (granted) {
- phoneCalls = true
- permissionDenied = false
- NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, true)
- } else {
- phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE)
- }
+ phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE)
}
},
onVoip = {
@@ -170,111 +144,47 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
onTest = { vm.buzz(loops = callPattern.loops) },
)
- AppsControlCard(
- master = master,
- query = appQuery,
- onQueryChange = { appQuery = it },
- enabledState = enabledState,
- patternState = patternState,
- onToggle = { app, value ->
- enabledState[app.id] = value
- NotifPrefs.setAppEnabled(context, app.id, value)
- },
- onPattern = { app, pattern ->
- patternState[app.id] = pattern
- NotifPrefs.setAppPattern(context, app.id, pattern)
- },
- onTest = { app -> vm.buzz(loops = (patternState[app.id] ?: app.category.defaultPattern).loops) },
- )
+ AppsControlCard(master, appQuery, { appQuery = it }, enabledState, patternState,
+ { app, value -> enabledState[app.id] = value; NotifPrefs.setAppEnabled(context, app.id, value) },
+ { app, pattern -> patternState[app.id] = pattern; NotifPrefs.setAppPattern(context, app.id, pattern) },
+ { app -> vm.buzz(loops = (patternState[app.id] ?: app.category.defaultPattern).loops) })
- NotificationBehaviourCard(
- master = master,
- wornOnly = wornOnly,
- quiet = quiet,
- alarmTimer = alarmTimer,
- allOther = allOther,
- onWornOnly = {
- wornOnly = it
- NotifPrefs.setBool(context, NotifPrefs.WORN, it)
- },
- onQuiet = {
- quiet = it
- NotifPrefs.setBool(context, NotifPrefs.QUIET, it)
- },
- onAlarm = {
- alarmTimer = it
- NotifPrefs.setBool(context, NotifPrefs.ALARM_TIMER, it)
- },
- onAllOther = {
- allOther = it
- NotifPrefs.setBool(context, NotifPrefs.ALL_OTHER, it)
- },
- )
+ NotificationBehaviourCard(master, wornOnly, quiet, alarmTimer, allOther,
+ { wornOnly = it; NotifPrefs.setBool(context, NotifPrefs.WORN, it) },
+ { quiet = it; NotifPrefs.setBool(context, NotifPrefs.QUIET, it) },
+ { alarmTimer = it; NotifPrefs.setBool(context, NotifPrefs.ALARM_TIMER, it) },
+ { allOther = it; NotifPrefs.setBool(context, NotifPrefs.ALL_OTHER, it) })
- AccessCard(context = context, notificationAccess = notificationAccess)
- DiagnosticsCard(
- enabled = master,
- connected = live.connected,
- encryptedBond = live.encryptedBond,
- worn = live.worn,
- notificationAccess = notificationAccess,
- callsEnabled = calls && (phoneCalls || voipCalls),
- )
+ AccessCard(context, notificationAccess)
+ DiagnosticsCard(context, master, live.connected, live.encryptedBond, live.worn, notificationAccess, calls && (phoneCalls || voipCalls))
}
}
@Composable
-private fun NotificationHero(
- enabled: Boolean,
- connected: Boolean,
- encryptedBond: Boolean,
- worn: Boolean,
- ready: Boolean,
- enabledApps: Int,
- onMasterChange: (Boolean) -> Unit,
- onTest: () -> Unit,
-) {
+private fun NotificationHero(enabled: Boolean, connected: Boolean, encryptedBond: Boolean, worn: Boolean, ready: Boolean, enabledApps: Int, onMasterChange: (Boolean) -> Unit, onTest: () -> Unit) {
NoopCard(padding = 20.dp, tint = if (ready) Palette.accent else Palette.hairline) {
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
- Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Overline("WRIST ALERTS", color = Palette.accent)
Text("Notification bridge", style = NoopType.title2, color = Palette.textPrimary)
- Text(
- when {
- !enabled -> "Off — your WHOOP will stay quiet."
- !connected -> "Waiting for your WHOOP to connect."
- !encryptedBond -> "Connected, but the secure command link is not ready."
- !worn -> "Strap connected, but not currently worn."
- else -> "Ready — important events can reach your wrist."
- },
- style = NoopType.subhead,
- color = Palette.textSecondary,
- )
+ Text(when {
+ !enabled -> "Off — your WHOOP will stay quiet."
+ !connected -> "Waiting for your WHOOP to connect."
+ !encryptedBond -> "Connected, but the secure command link is not ready."
+ !worn -> "Strap connected, but not currently worn."
+ else -> "Ready — important events can reach your wrist."
+ }, style = NoopType.subhead, color = Palette.textSecondary)
}
- NoopSwitch(checked = enabled, onChange = onMasterChange, enabled = true, label = "Wrist alerts")
+ NoopSwitch(enabled, onMasterChange, true, "Wrist alerts")
}
-
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- StatusChip(
- icon = Icons.Filled.Watch,
- text = if (connected) "WHOOP connected" else "WHOOP offline",
- positive = connected,
- )
- StatusChip(
- icon = Icons.Filled.CheckCircle,
- text = if (encryptedBond) "Secure link" else "Link not ready",
- positive = encryptedBond,
- )
- StatusChip(
- icon = Icons.Filled.NotificationsActive,
- text = "$enabledApps apps",
- positive = enabledApps > 0,
- )
+ StatusChip(Icons.Filled.Watch, if (connected) "WHOOP connected" else "WHOOP offline", connected)
+ StatusChip(Icons.Filled.CheckCircle, if (encryptedBond) "Secure link" else "Link not ready", encryptedBond)
+ StatusChip(Icons.Filled.NotificationsActive, "$enabledApps apps", enabledApps > 0)
}
-
Row(verticalAlignment = Alignment.CenterVertically) {
- Column(modifier = Modifier.weight(1f)) {
+ Column(Modifier.weight(1f)) {
Text("Test your wrist", style = NoopType.body, color = Palette.textPrimary)
Text("Sends a short two-pulse test now.", style = NoopType.footnote, color = Palette.textTertiary)
}
@@ -285,37 +195,22 @@ private fun NotificationHero(
}
@Composable
-private fun CallsControlCard(
- master: Boolean,
- enabled: Boolean,
- phoneEnabled: Boolean,
- voipEnabled: Boolean,
- pattern: BuzzPattern,
- commandReady: Boolean,
- permissionDenied: Boolean,
- onEnabled: (Boolean) -> Unit,
- onPhone: (Boolean) -> Unit,
- onVoip: (Boolean) -> Unit,
- onPattern: (BuzzPattern) -> Unit,
- onTest: () -> Unit,
-) {
+private fun CallsControlCard(master: Boolean, enabled: Boolean, phoneEnabled: Boolean, voipEnabled: Boolean, pattern: BuzzPattern, commandReady: Boolean, permissionDenied: Boolean, onEnabled: (Boolean) -> Unit, onPhone: (Boolean) -> Unit, onVoip: (Boolean) -> Unit, onPattern: (BuzzPattern) -> Unit, onTest: () -> Unit) {
SectionCard(Icons.Filled.Call, "Calls", "Phone calls deserve the most obvious wrist cue.") {
ToggleRow("Incoming calls", "Master switch for call haptics.", enabled, master, onEnabled)
if (enabled) {
DividerLine()
ToggleRow("Phone calls", "Native cellular calls. NOOP does not upload numbers or call logs.", phoneEnabled, master, onPhone)
- if (permissionDenied) {
- Text("Phone permission was denied. Turn it on in Android Settings to detect calls.", style = NoopType.footnote, color = Palette.statusCritical)
- }
+ if (permissionDenied) Text("Phone permission was denied. Turn it on in Android Settings to detect calls.", style = NoopType.footnote, color = Palette.statusCritical)
DividerLine()
ToggleRow("VoIP calls", "Best-effort detection for supported calling apps.", voipEnabled, master, onVoip)
DividerLine()
Row(verticalAlignment = Alignment.CenterVertically) {
- Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Call pattern", style = NoopType.body, color = Palette.textPrimary)
Text("Immediate buzz + finite reminders while ringing.", style = NoopType.footnote, color = Palette.textTertiary)
}
- PatternPicker(pattern, master, "call alerts", onPattern)
+ PatternPicker(pattern, master, onPattern)
ActionPill("Test", Icons.Filled.GraphicEq, commandReady && master, onTest)
}
}
@@ -323,109 +218,45 @@ private fun CallsControlCard(
}
@Composable
-private fun AppsControlCard(
- master: Boolean,
- query: String,
- onQueryChange: (String) -> Unit,
- enabledState: SnapshotStateMap,
- patternState: SnapshotStateMap,
- onToggle: (NotifApp, Boolean) -> Unit,
- onPattern: (NotifApp, BuzzPattern) -> Unit,
- onTest: (NotifApp) -> Unit,
-) {
+private fun AppsControlCard(master: Boolean, query: String, onQueryChange: (String) -> Unit, enabledState: SnapshotStateMap, patternState: SnapshotStateMap, onToggle: (NotifApp, Boolean) -> Unit, onPattern: (NotifApp, BuzzPattern) -> Unit, onTest: (NotifApp) -> Unit) {
val filtered = remember(query) {
val normalized = query.trim().lowercase()
- if (normalized.isEmpty()) notifCatalog
- else notifCatalog.filter { app ->
- app.name.lowercase().contains(normalized) || app.id.lowercase().contains(normalized)
- }
+ if (normalized.isEmpty()) notifCatalog else notifCatalog.filter { it.name.lowercase().contains(normalized) || it.id.lowercase().contains(normalized) }
}
-
SectionCard(Icons.Filled.NotificationsActive, "Apps", "Choose exactly which notifications deserve your attention.") {
- OutlinedTextField(
- value = query,
- onValueChange = onQueryChange,
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
- placeholder = { Text("Search apps") },
- shape = RoundedCornerShape(12.dp),
- )
-
+ OutlinedTextField(value = query, onValueChange = onQueryChange, modifier = Modifier.fillMaxWidth(), singleLine = true, leadingIcon = { Icon(Icons.Filled.Search, null) }, placeholder = { Text("Search apps") }, shape = RoundedCornerShape(12.dp))
if (filtered.isEmpty()) {
Text("No supported app matches this search.", style = NoopType.footnote, color = Palette.textTertiary)
} else {
- val grouped = filtered.groupBy { it.category }
- grouped.forEach { (category, apps) ->
+ filtered.groupBy { it.category }.forEach { (category, apps) ->
Text(category.title, style = NoopType.caption, color = Palette.accent)
apps.forEachIndexed { index, app ->
- AppAlertRow(
- app = app,
- enabled = enabledState[app.id] ?: false,
- pattern = patternState[app.id] ?: app.category.defaultPattern,
- interactive = master,
- onToggle = { onToggle(app, it) },
- onPattern = { onPattern(app, it) },
- onTest = { onTest(app) },
- )
+ AppAlertRow(app, enabledState[app.id] ?: false, patternState[app.id] ?: app.category.defaultPattern, master, { onToggle(app, it) }, { onPattern(app, it) }, { onTest(app) })
if (index != apps.lastIndex) DividerLine()
}
- if (category != grouped.keys.last()) Spacer(Modifier.size(10.dp))
}
}
}
}
@Composable
-private fun AppAlertRow(
- app: NotifApp,
- enabled: Boolean,
- pattern: BuzzPattern,
- interactive: Boolean,
- onToggle: (Boolean) -> Unit,
- onPattern: (BuzzPattern) -> Unit,
- onTest: () -> Unit,
-) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(12.dp))
- .background(if (enabled) Palette.accentMuted.copy(alpha = 0.45f) else Palette.surfaceInset.copy(alpha = 0.45f))
- .padding(horizontal = 10.dp, vertical = 9.dp)
- .alpha(if (interactive) 1f else Palette.disabledOpacity),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- Box(
- modifier = Modifier.size(34.dp).clip(RoundedCornerShape(9.dp)).background(Palette.surfaceRaised),
- contentAlignment = Alignment.Center,
- ) {
- Icon(app.glyph, contentDescription = null, tint = Palette.textSecondary, modifier = Modifier.size(18.dp))
- }
- Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+private fun AppAlertRow(app: NotifApp, enabled: Boolean, pattern: BuzzPattern, interactive: Boolean, onToggle: (Boolean) -> Unit, onPattern: (BuzzPattern) -> Unit, onTest: () -> Unit) {
+ Row(Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(if (enabled) Palette.accentMuted.copy(alpha = 0.45f) else Palette.surfaceInset.copy(alpha = 0.45f)).padding(horizontal = 10.dp, vertical = 9.dp).alpha(if (interactive) 1f else Palette.disabledOpacity), Alignment.CenterVertically, Arrangement.spacedBy(10.dp)) {
+ Box(Modifier.size(34.dp).clip(RoundedCornerShape(9.dp)).background(Palette.surfaceRaised), Alignment.Center) { Icon(app.glyph, null, tint = Palette.textSecondary, modifier = Modifier.size(18.dp)) }
+ Column(Modifier.weight(1f), Arrangement.spacedBy(2.dp)) {
Text(app.name, style = NoopType.body, color = Palette.textPrimary)
Text(if (enabled) "Wrist alert enabled" else "Off", style = NoopType.footnote, color = if (enabled) Palette.accent else Palette.textTertiary)
}
if (enabled) {
- PatternPicker(pattern, interactive, app.name, onPattern)
+ PatternPicker(pattern, interactive, onPattern)
ActionPill("Test", Icons.Filled.GraphicEq, interactive, onTest)
}
- NoopSwitch(checked = enabled, onChange = onToggle, enabled = interactive, label = "${app.name} wrist alert")
+ NoopSwitch(enabled, onToggle, interactive, "${app.name} wrist alert")
}
}
@Composable
-private fun NotificationBehaviourCard(
- master: Boolean,
- wornOnly: Boolean,
- quiet: Boolean,
- alarmTimer: Boolean,
- allOther: Boolean,
- onWornOnly: (Boolean) -> Unit,
- onQuiet: (Boolean) -> Unit,
- onAlarm: (Boolean) -> Unit,
- onAllOther: (Boolean) -> Unit,
-) {
+private fun NotificationBehaviourCard(master: Boolean, wornOnly: Boolean, quiet: Boolean, alarmTimer: Boolean, allOther: Boolean, onWornOnly: (Boolean) -> Unit, onQuiet: (Boolean) -> Unit, onAlarm: (Boolean) -> Unit, onAllOther: (Boolean) -> Unit) {
SectionCard(Icons.Filled.Tune, "Behaviour", "Rules that keep wrist alerts useful instead of noisy.") {
ToggleRow("Only when worn", "Don't buzz an unattended strap.", wornOnly, master, onWornOnly)
DividerLine()
@@ -439,83 +270,30 @@ private fun NotificationBehaviourCard(
@Composable
private fun AccessCard(context: Context, notificationAccess: Boolean) {
+ val phonePermission = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED
SectionCard(Icons.Filled.Settings, "Permissions & privacy", "NOOP needs Android notification access to see app events.") {
- StatusRow(
- icon = Icons.Filled.NotificationsActive,
- title = "Notification access",
- detail = if (notificationAccess) "Enabled — app events can be received locally." else "Required for app and VoIP notification detection.",
- positive = notificationAccess,
- )
- Spacer(Modifier.size(6.dp))
- StatusRow(
- icon = Icons.Filled.Phone,
- title = "Phone state permission",
- detail = if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
- "Enabled — native call state can be detected."
- } else {
- "Required only for native cellular call detection."
- },
- positive = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED,
- )
+ StatusRow(Icons.Filled.NotificationsActive, "Notification access", if (notificationAccess) "Enabled — app events can be received locally." else "Required for app and VoIP notification detection.", notificationAccess)
+ StatusRow(Icons.Filled.Phone, "Phone state permission", if (phonePermission) "Enabled — native call state can be detected." else "Required only for native cellular call detection.", phonePermission)
DividerLine()
- Text(
- "Notification contents stay on this phone. NOOP uses the posting app and event state to decide whether to buzz your WHOOP.",
- style = NoopType.footnote,
- color = Palette.textSecondary,
- )
- ActionPill("Open Notification Access", Icons.Filled.OpenInNew, true) {
- runCatching {
- context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
- }
- }
+ Text("Notification contents stay on this phone. NOOP uses the posting app and event state to decide whether to buzz your WHOOP.", style = NoopType.footnote, color = Palette.textSecondary)
+ ActionPill("Open Notification Access", Icons.Filled.OpenInNew, true) { runCatching { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } }
}
}
@Composable
-private fun DiagnosticsCard(
- enabled: Boolean,
- connected: Boolean,
- encryptedBond: Boolean,
- worn: Boolean,
- notificationAccess: Boolean,
- callsEnabled: Boolean,
-) {
- val ready = enabled && connected && encryptedBond && notificationAccess && (!NotifPrefs.getBool(LocalContext.current, NotifPrefs.WORN, true) || worn)
+private fun DiagnosticsCard(context: Context, enabled: Boolean, connected: Boolean, encryptedBond: Boolean, worn: Boolean, notificationAccess: Boolean, callsEnabled: Boolean) {
+ val wearGate = NotifPrefs.getBool(context, NotifPrefs.WORN, true)
+ val ready = enabled && connected && encryptedBond && notificationAccess && (!wearGate || worn)
SectionCard(Icons.Filled.GraphicEq, "Delivery diagnostics", "A quick explanation of why an alert can or cannot reach your wrist.") {
- StatusRow(
- icon = Icons.Filled.Watch,
- title = "WHOOP connection",
- detail = if (connected) "Connected" else "Disconnected — connect your WHOOP first.",
- positive = connected,
- )
- StatusRow(
- icon = Icons.Filled.CheckCircle,
- title = "Secure command link",
- detail = if (encryptedBond) "Ready for haptic commands" else "Not ready — haptics will be held.",
- positive = encryptedBond,
- )
- StatusRow(
- icon = Icons.Filled.NotificationsActive,
- title = "Notification listener",
- detail = if (notificationAccess) "Ready for app events" else "Disabled — app alerts and VoIP detection are unavailable.",
- positive = notificationAccess,
- )
- StatusRow(
- icon = Icons.Filled.Call,
- title = "Call alerts",
- detail = if (callsEnabled) "Enabled" else "Disabled",
- positive = callsEnabled,
- )
+ StatusRow(Icons.Filled.Watch, "WHOOP connection", if (connected) "Connected" else "Disconnected — connect your WHOOP first.", connected)
+ StatusRow(Icons.Filled.CheckCircle, "Secure command link", if (encryptedBond) "Ready for haptic commands" else "Not ready — haptics will be held.", encryptedBond)
+ StatusRow(Icons.Filled.NotificationsActive, "Notification listener", if (notificationAccess) "Ready for app events" else "Disabled — app alerts and VoIP detection are unavailable.", notificationAccess)
+ StatusRow(Icons.Filled.Call, "Call alerts", if (callsEnabled) "Enabled" else "Disabled", callsEnabled)
DividerLine()
Row(verticalAlignment = Alignment.CenterVertically) {
- Column(modifier = Modifier.weight(1f)) {
+ Column(Modifier.weight(1f)) {
Text("Overall readiness", style = NoopType.body, color = Palette.textPrimary)
- Text(
- if (ready) "Everything required for local wrist delivery is ready."
- else "One or more prerequisites need attention.",
- style = NoopType.footnote,
- color = Palette.textTertiary,
- )
+ Text(if (ready) "Everything required for local wrist delivery is ready." else "One or more prerequisites need attention.", style = NoopType.footnote, color = Palette.textTertiary)
}
StatusChip(Icons.Filled.CheckCircle, if (ready) "READY" else "CHECK", ready)
}
@@ -524,13 +302,9 @@ private fun DiagnosticsCard(
@Composable
private fun StatusRow(icon: ImageVector, title: String, detail: String, positive: Boolean) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- Icon(icon, contentDescription = null, tint = if (positive) Palette.accent else Palette.textTertiary, modifier = Modifier.size(18.dp))
- Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Row(Modifier.fillMaxWidth().padding(vertical = 5.dp), Alignment.CenterVertically, Arrangement.spacedBy(10.dp)) {
+ Icon(icon, null, tint = if (positive) Palette.accent else Palette.textTertiary, modifier = Modifier.size(18.dp))
+ Column(Modifier.weight(1f), Arrangement.spacedBy(2.dp)) {
Text(title, style = NoopType.body, color = Palette.textPrimary)
Text(detail, style = NoopType.footnote, color = Palette.textTertiary)
}
@@ -540,10 +314,7 @@ private fun StatusRow(icon: ImageVector, title: String, detail: String, positive
private fun notificationAccessGranted(context: Context): Boolean {
val enabled = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners") ?: return false
- val component = ComponentName(context, NoopNotificationListenerService::class.java)
- return enabled.split(":").any { raw ->
- runCatching { ComponentName.unflattenFromString(raw) == component }.getOrDefault(false)
- }
+ return enabled.split(":").any { component -> component.startsWith(context.packageName) }
}
@Composable
@@ -551,16 +322,8 @@ private fun SectionCard(icon: ImageVector, title: String, subtitle: String, cont
NoopCard(padding = 18.dp, tint = Palette.hairline) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Box(
- modifier = Modifier.size(34.dp).clip(RoundedCornerShape(10.dp)).background(Palette.accentMuted),
- contentAlignment = Alignment.Center,
- ) {
- Icon(icon, contentDescription = null, tint = Palette.accent, modifier = Modifier.size(18.dp))
- }
- Column {
- Text(title, style = NoopType.title2, color = Palette.textPrimary)
- Text(subtitle, style = NoopType.footnote, color = Palette.textSecondary)
- }
+ Box(Modifier.size(34.dp).clip(RoundedCornerShape(10.dp)).background(Palette.accentMuted), Alignment.Center) { Icon(icon, null, tint = Palette.accent, modifier = Modifier.size(18.dp)) }
+ Column { Text(title, style = NoopType.title2, color = Palette.textPrimary); Text(subtitle, style = NoopType.footnote, color = Palette.textSecondary) }
}
content()
}
@@ -569,11 +332,8 @@ private fun SectionCard(icon: ImageVector, title: String, subtitle: String, cont
@Composable
private fun ToggleRow(label: String, help: String, checked: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) {
- Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
- Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
- Text(label, style = NoopType.body, color = Palette.textPrimary)
- Text(help, style = NoopType.footnote, color = Palette.textTertiary)
- }
+ Row(Modifier.fillMaxWidth(), Alignment.CenterVertically) {
+ Column(Modifier.weight(1f), Arrangement.spacedBy(2.dp)) { Text(label, style = NoopType.body, color = Palette.textPrimary); Text(help, style = NoopType.footnote, color = Palette.textTertiary) }
Spacer(Modifier.width(12.dp))
NoopSwitch(checked, onChange, enabled, label)
}
@@ -582,16 +342,8 @@ private fun ToggleRow(label: String, help: String, checked: Boolean, enabled: Bo
@Composable
private fun StatusChip(icon: ImageVector, text: String, positive: Boolean) {
val tint = if (positive) Palette.accent else Palette.textTertiary
- Row(
- modifier = Modifier
- .clip(RoundedCornerShape(50))
- .background(Palette.surfaceInset)
- .border(1.dp, tint.copy(alpha = 0.22f), RoundedCornerShape(50))
- .padding(horizontal = 10.dp, vertical = 6.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(13.dp))
+ Row(Modifier.clip(RoundedCornerShape(50)).background(Palette.surfaceInset).border(1.dp, tint.copy(alpha = 0.22f), RoundedCornerShape(50)).padding(horizontal = 10.dp, vertical = 6.dp), Alignment.CenterVertically, Arrangement.spacedBy(6.dp)) {
+ Icon(icon, null, tint = tint, modifier = Modifier.size(13.dp))
Text(text, style = NoopType.caption, color = tint)
}
}
@@ -599,35 +351,20 @@ private fun StatusChip(icon: ImageVector, text: String, positive: Boolean) {
@Composable
private fun ActionPill(label: String, icon: ImageVector, enabled: Boolean, onClick: () -> Unit) {
val tint = if (enabled) Palette.accent else Palette.textTertiary
- Row(
- modifier = Modifier
- .clip(RoundedCornerShape(50))
- .background(tint.copy(alpha = if (enabled) 0.12f else 0.04f))
- .border(1.dp, tint.copy(alpha = 0.25f), RoundedCornerShape(50))
- .clickable(enabled = enabled, onClick = onClick)
- .padding(horizontal = 10.dp, vertical = 6.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(5.dp),
- ) {
- Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(13.dp))
+ Row(Modifier.clip(RoundedCornerShape(50)).background(tint.copy(alpha = if (enabled) 0.12f else 0.04f)).border(1.dp, tint.copy(alpha = 0.25f), RoundedCornerShape(50)).clickable(enabled = enabled, onClick = onClick).padding(horizontal = 10.dp, vertical = 6.dp), Alignment.CenterVertically, Arrangement.spacedBy(5.dp)) {
+ Icon(icon, null, tint = tint, modifier = Modifier.size(13.dp))
Text(label, style = NoopType.caption, color = tint)
}
}
@Composable
-private fun PatternPicker(pattern: BuzzPattern, enabled: Boolean, name: String, onSelect: (BuzzPattern) -> Unit) {
+private fun PatternPicker(pattern: BuzzPattern, enabled: Boolean, onSelect: (BuzzPattern) -> Unit) {
var expanded by remember { mutableStateOf(false) }
Box {
ActionPill(pattern.label, Icons.Filled.GraphicEq, enabled) { expanded = true }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
BuzzPattern.entries.forEach { option ->
- DropdownMenuItem(
- text = { Text(if (option == pattern) "✓ ${option.label}" else option.label, color = Palette.textPrimary) },
- onClick = {
- onSelect(option)
- expanded = false
- },
- )
+ DropdownMenuItem(text = { Text(if (option == pattern) "✓ ${option.label}" else option.label, color = Palette.textPrimary) }, onClick = { onSelect(option); expanded = false })
}
}
}
@@ -635,16 +372,5 @@ private fun PatternPicker(pattern: BuzzPattern, enabled: Boolean, name: String,
@Composable
private fun NoopSwitch(checked: Boolean, onChange: (Boolean) -> Unit, enabled: Boolean, label: String) {
- Switch(
- checked = checked,
- onCheckedChange = onChange,
- enabled = enabled,
- colors = SwitchDefaults.colors(
- checkedThumbColor = Palette.surfaceBase,
- checkedTrackColor = Palette.accent,
- uncheckedThumbColor = Palette.textSecondary,
- uncheckedTrackColor = Palette.surfaceInset,
- uncheckedBorderColor = Palette.hairline,
- ),
- )
+ Switch(checked = checked, onCheckedChange = onChange, enabled = enabled, colors = SwitchDefaults.colors(checkedThumbColor = Palette.surfaceBase, checkedTrackColor = Palette.accent, uncheckedThumbColor = Palette.textSecondary, uncheckedTrackColor = Palette.surfaceInset, uncheckedBorderColor = Palette.hairline))
}
From 37bc26b610684abd6db119e33d3d3a231f034ea0 Mon Sep 17 00:00:00 2001
From: Binesh Ellupurayil Balachandran
<31845423+binesheb@users.noreply.github.com>
Date: Tue, 11 Aug 2026 12:49:02 +0530
Subject: [PATCH 08/10] Fix notification center imports after CI review
---
.../app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt | 1 -
1 file changed, 1 deletion(-)
diff --git a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
index 8a4615a3b2..1c5d938737 100644
--- a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
@@ -48,7 +48,6 @@ import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.draw.background
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
From 08ce1053715c0997e2efbd7a7dcf69b33fe10092 Mon Sep 17 00:00:00 2001
From: Binesh Ellupurayil Balachandran
<31845423+binesheb@users.noreply.github.com>
Date: Tue, 11 Aug 2026 12:49:54 +0530
Subject: [PATCH 09/10] Fix Compose row parameter ordering
---
.../com/noop/ui/NotificationsSettingsScreen.kt | 18 +++++++++++++-----
1 file changed, 13 insertions(+), 5 deletions(-)
diff --git a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
index 1c5d938737..d9220bda1e 100644
--- a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
@@ -240,9 +240,13 @@ private fun AppsControlCard(master: Boolean, query: String, onQueryChange: (Stri
@Composable
private fun AppAlertRow(app: NotifApp, enabled: Boolean, pattern: BuzzPattern, interactive: Boolean, onToggle: (Boolean) -> Unit, onPattern: (BuzzPattern) -> Unit, onTest: () -> Unit) {
- Row(Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(if (enabled) Palette.accentMuted.copy(alpha = 0.45f) else Palette.surfaceInset.copy(alpha = 0.45f)).padding(horizontal = 10.dp, vertical = 9.dp).alpha(if (interactive) 1f else Palette.disabledOpacity), Alignment.CenterVertically, Arrangement.spacedBy(10.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(if (enabled) Palette.accentMuted.copy(alpha = 0.45f) else Palette.surfaceInset.copy(alpha = 0.45f)).padding(horizontal = 10.dp, vertical = 9.dp).alpha(if (interactive) 1f else Palette.disabledOpacity),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
Box(Modifier.size(34.dp).clip(RoundedCornerShape(9.dp)).background(Palette.surfaceRaised), Alignment.Center) { Icon(app.glyph, null, tint = Palette.textSecondary, modifier = Modifier.size(18.dp)) }
- Column(Modifier.weight(1f), Arrangement.spacedBy(2.dp)) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(app.name, style = NoopType.body, color = Palette.textPrimary)
Text(if (enabled) "Wrist alert enabled" else "Off", style = NoopType.footnote, color = if (enabled) Palette.accent else Palette.textTertiary)
}
@@ -301,9 +305,13 @@ private fun DiagnosticsCard(context: Context, enabled: Boolean, connected: Boole
@Composable
private fun StatusRow(icon: ImageVector, title: String, detail: String, positive: Boolean) {
- Row(Modifier.fillMaxWidth().padding(vertical = 5.dp), Alignment.CenterVertically, Arrangement.spacedBy(10.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
Icon(icon, null, tint = if (positive) Palette.accent else Palette.textTertiary, modifier = Modifier.size(18.dp))
- Column(Modifier.weight(1f), Arrangement.spacedBy(2.dp)) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = NoopType.body, color = Palette.textPrimary)
Text(detail, style = NoopType.footnote, color = Palette.textTertiary)
}
@@ -332,7 +340,7 @@ private fun SectionCard(icon: ImageVector, title: String, subtitle: String, cont
@Composable
private fun ToggleRow(label: String, help: String, checked: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) {
Row(Modifier.fillMaxWidth(), Alignment.CenterVertically) {
- Column(Modifier.weight(1f), Arrangement.spacedBy(2.dp)) { Text(label, style = NoopType.body, color = Palette.textPrimary); Text(help, style = NoopType.footnote, color = Palette.textTertiary) }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(label, style = NoopType.body, color = Palette.textPrimary); Text(help, style = NoopType.footnote, color = Palette.textTertiary) }
Spacer(Modifier.width(12.dp))
NoopSwitch(checked, onChange, enabled, label)
}
From ca1adff9384f69eb9ec0a1fc15bf618141373cc2 Mon Sep 17 00:00:00 2001
From: Binesh Ellupurayil Balachandran
<31845423+binesheb@users.noreply.github.com>
Date: Tue, 11 Aug 2026 12:50:14 +0530
Subject: [PATCH 10/10] Fix notification center Compose layout parameters
---
.../noop/ui/NotificationsSettingsScreen.kt | 75 ++++++-------------
1 file changed, 23 insertions(+), 52 deletions(-)
diff --git a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
index d9220bda1e..b552d16ac3 100644
--- a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt
@@ -61,7 +61,6 @@ import com.noop.notif.CallAlertSource
fun NotificationsSettingsScreen(vm: AppViewModel) {
val context = LocalContext.current
val live by vm.live.collectAsStateWithLifecycle()
-
var master by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.MASTER, false)) }
var calls by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_MASTER, false)) }
var phoneCalls by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_PHONE, false)) }
@@ -74,25 +73,13 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
var permissionDenied by remember { mutableStateOf(false) }
var appQuery by remember { mutableStateOf("") }
- val enabledState: SnapshotStateMap = remember {
- mutableStateMapOf().apply {
- notifCatalog.forEach { put(it.id, NotifPrefs.appEnabled(context, it.id)) }
- }
- }
- val patternState: SnapshotStateMap = remember {
- mutableStateMapOf().apply {
- notifCatalog.forEach { put(it.id, NotifPrefs.appPattern(context, it)) }
- }
- }
-
- val phonePermissionLauncher = rememberLauncherForActivityResult(
- ActivityResultContracts.RequestPermission(),
- ) { granted ->
+ val enabledState = remember { mutableStateMapOf().apply { notifCatalog.forEach { put(it.id, NotifPrefs.appEnabled(context, it.id)) } } }
+ val patternState = remember { mutableStateMapOf().apply { notifCatalog.forEach { put(it.id, NotifPrefs.appPattern(context, it)) } } }
+ val phonePermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
phoneCalls = granted
permissionDenied = !granted
NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, granted)
}
-
val enabledApps = enabledState.values.count { it }
val deliveryReady = master && live.connected && live.encryptedBond && (!wornOnly || live.worn) && !quiet
val notificationAccess = notificationAccessGranted(context)
@@ -104,44 +91,21 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
if (!it) CallAlertController.stopAll()
}) { vm.buzz(loops = 2) }
- CallsControlCard(
- master = master,
- enabled = calls,
- phoneEnabled = phoneCalls,
- voipEnabled = voipCalls,
- pattern = callPattern,
- commandReady = live.connected && live.encryptedBond,
- permissionDenied = permissionDenied,
- onEnabled = {
- calls = it
- NotifPrefs.setBool(context, NotifPrefs.CALLS_MASTER, it)
- if (!it) CallAlertController.stopAll()
- },
- onPhone = { value ->
+ CallsControlCard(master, calls, phoneCalls, voipCalls, callPattern, live.connected && live.encryptedBond, permissionDenied,
+ { calls = it; NotifPrefs.setBool(context, NotifPrefs.CALLS_MASTER, it); if (!it) CallAlertController.stopAll() },
+ { value ->
if (!value) {
- phoneCalls = false
- permissionDenied = false
+ phoneCalls = false; permissionDenied = false
NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, false)
CallAlertController.stopSource(CallAlertSource.PHONE)
} else if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
- phoneCalls = true
- permissionDenied = false
+ phoneCalls = true; permissionDenied = false
NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, true)
- } else {
- phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE)
- }
+ } else phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE)
},
- onVoip = {
- voipCalls = it
- NotifPrefs.setBool(context, NotifPrefs.CALLS_VOIP, it)
- if (!it) CallAlertController.stopSource(CallAlertSource.VOIP)
- },
- onPattern = {
- callPattern = it
- NotifPrefs.setCallPattern(context, it)
- },
- onTest = { vm.buzz(loops = callPattern.loops) },
- )
+ { voipCalls = it; NotifPrefs.setBool(context, NotifPrefs.CALLS_VOIP, it); if (!it) CallAlertController.stopSource(CallAlertSource.VOIP) },
+ { callPattern = it; NotifPrefs.setCallPattern(context, it) },
+ { vm.buzz(loops = callPattern.loops) })
AppsControlCard(master, appQuery, { appQuery = it }, enabledState, patternState,
{ app, value -> enabledState[app.id] = value; NotifPrefs.setAppEnabled(context, app.id, value) },
@@ -153,7 +117,6 @@ fun NotificationsSettingsScreen(vm: AppViewModel) {
{ quiet = it; NotifPrefs.setBool(context, NotifPrefs.QUIET, it) },
{ alarmTimer = it; NotifPrefs.setBool(context, NotifPrefs.ALARM_TIMER, it) },
{ allOther = it; NotifPrefs.setBool(context, NotifPrefs.ALL_OTHER, it) })
-
AccessCard(context, notificationAccess)
DiagnosticsCard(context, master, live.connected, live.encryptedBond, live.worn, notificationAccess, calls && (phoneCalls || voipCalls))
}
@@ -339,7 +302,7 @@ private fun SectionCard(icon: ImageVector, title: String, subtitle: String, cont
@Composable
private fun ToggleRow(label: String, help: String, checked: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) {
- Row(Modifier.fillMaxWidth(), Alignment.CenterVertically) {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(label, style = NoopType.body, color = Palette.textPrimary); Text(help, style = NoopType.footnote, color = Palette.textTertiary) }
Spacer(Modifier.width(12.dp))
NoopSwitch(checked, onChange, enabled, label)
@@ -349,7 +312,11 @@ private fun ToggleRow(label: String, help: String, checked: Boolean, enabled: Bo
@Composable
private fun StatusChip(icon: ImageVector, text: String, positive: Boolean) {
val tint = if (positive) Palette.accent else Palette.textTertiary
- Row(Modifier.clip(RoundedCornerShape(50)).background(Palette.surfaceInset).border(1.dp, tint.copy(alpha = 0.22f), RoundedCornerShape(50)).padding(horizontal = 10.dp, vertical = 6.dp), Alignment.CenterVertically, Arrangement.spacedBy(6.dp)) {
+ Row(
+ modifier = Modifier.clip(RoundedCornerShape(50)).background(Palette.surfaceInset).border(1.dp, tint.copy(alpha = 0.22f), RoundedCornerShape(50)).padding(horizontal = 10.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
Icon(icon, null, tint = tint, modifier = Modifier.size(13.dp))
Text(text, style = NoopType.caption, color = tint)
}
@@ -358,7 +325,11 @@ private fun StatusChip(icon: ImageVector, text: String, positive: Boolean) {
@Composable
private fun ActionPill(label: String, icon: ImageVector, enabled: Boolean, onClick: () -> Unit) {
val tint = if (enabled) Palette.accent else Palette.textTertiary
- Row(Modifier.clip(RoundedCornerShape(50)).background(tint.copy(alpha = if (enabled) 0.12f else 0.04f)).border(1.dp, tint.copy(alpha = 0.25f), RoundedCornerShape(50)).clickable(enabled = enabled, onClick = onClick).padding(horizontal = 10.dp, vertical = 6.dp), Alignment.CenterVertically, Arrangement.spacedBy(5.dp)) {
+ Row(
+ modifier = Modifier.clip(RoundedCornerShape(50)).background(tint.copy(alpha = if (enabled) 0.12f else 0.04f)).border(1.dp, tint.copy(alpha = 0.25f), RoundedCornerShape(50)).clickable(enabled = enabled, onClick = onClick).padding(horizontal = 10.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ ) {
Icon(icon, null, tint = tint, modifier = Modifier.size(13.dp))
Text(label, style = NoopType.caption, color = tint)
}