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)
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..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,16 +12,14 @@ 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.
+ * One local scheduler for all active calls.
+ *
+ * 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 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
+ private const val MAX_RING_WINDOW_MS = 5 * 60_000L
private val handler = Handler(Looper.getMainLooper())
private val policy = CallAlertPolicy()
@@ -32,22 +30,27 @@ internal object CallAlertController {
private val repeatRunnable = object : Runnable {
override fun run() {
- val ctx = appContext ?: return
- maybeBuzz(ctx)
+ appContext?.let(::maybeBuzz)
}
}
- /** Self-heal: clear all sources if a stop event was missed (see [MAX_RING_WINDOW_MS]). */
+ /** 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("${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 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)
+
if (wasInactive) {
buzzCount = 0
lastBuzzAtMs = null
@@ -57,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()
@@ -74,15 +80,25 @@ 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 (!deliveryAllowed(context)) {
+ if (!policy.shouldBuzz(true, buzzCount, lastBuzzAtMs, now)) return
+
+ val ble = (context.applicationContext as? NoopApplication)?.ble ?: run {
scheduleNext()
return
}
- val ble = (context.applicationContext as? NoopApplication)?.ble ?: return
+ // 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
@@ -123,12 +139,17 @@ 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
- if (NotifPrefs.getBool(context, NotifPrefs.WORN, true) && !ble.state.value.worn) return false
+
+ val state = ble.state.value
+ 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 46da87aaf0..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,14 +1,21 @@
package com.noop.notif
-/**
- * Small pure policy for repeated call buzzes. The controller owns Android scheduling;
- * this object keeps the cadence testable.
- */
+/** Pure, deterministic cadence rules for incoming-call haptics. */
internal data class CallAlertPolicy(
- val repeatIntervalMs: Long = 8_000L,
- val maxBuzzes: Int = 4,
+ val repeatIntervalMs: Long = 6_000L,
+ val maxBuzzes: Int = 6,
) {
- fun shouldBuzz(active: Boolean, buzzCount: Int, lastBuzzAtMs: Long?, nowMs: Long): Boolean {
+ 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/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,
)
}
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..b552d16ac3 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.Search
+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.OutlinedTextField
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,1000 +51,304 @@ 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.
- */
-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
+ 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) }
+ var appQuery by remember { mutableStateOf("") }
+
+ 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)
}
-
- // 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)) }
- }
- }
- val patternState: SnapshotStateMap = remember {
- mutableStateMapOf().apply {
- 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),
- )
- }
-
- 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) },
- )
- }
-
- DeliveryNote()
- }
-
- CallsCard(
- masterEnabled = masterEnabled,
- callsEnabled = callsEnabled,
- phoneCallsEnabled = phoneCallsEnabled,
- voipCallsEnabled = voipCallsEnabled,
- pattern = callsPattern,
- bonded = live.bonded,
- permissionDenied = phonePermissionDenied,
- onCallsEnabled = {
- callsEnabled = it
- NotifPrefs.setBool(context, NotifPrefs.CALLS_MASTER, it)
- if (!it) CallAlertController.stopAll()
- },
- onPhoneCallsEnabled = { value ->
+ val enabledApps = enabledState.values.count { it }
+ 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(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, 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) {
- 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
+ } 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 {
- phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE)
- }
+ } else phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE)
},
- onVoipCallsEnabled = {
- voipCallsEnabled = it
- NotifPrefs.setBool(context, NotifPrefs.CALLS_VOIP, it)
- if (!it) CallAlertController.stopSource(CallAlertSource.VOIP)
- },
- onPattern = {
- callsPattern = it
- NotifPrefs.setCallPattern(context, it)
- },
- onTest = { vm.buzz(loops = callsPattern.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)
- },
- )
- }
- }
-
- // #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),
- )
- }
-
- // 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) },
- )
- }
-
- // 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)
- },
- )
- Spacer(Modifier.weight(1f))
- }
- }
- }
-
- // 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)
- },
- )
- }
+ { 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) },
+ { 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, 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, notificationAccess)
+ DiagnosticsCard(context, master, live.connected, live.encryptedBond, live.worn, notificationAccess, calls && (phoneCalls || voipCalls))
}
}
-// 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,
- pattern: BuzzPattern,
- bonded: Boolean,
- permissionDenied: Boolean,
- onCallsEnabled: (Boolean) -> Unit,
- onPhoneCallsEnabled: (Boolean) -> Unit,
- onVoipCallsEnabled: (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)
+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.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)
}
- NoopSwitch(
- checked = callsEnabled,
- onChange = onCallsEnabled,
- enabled = masterEnabled,
- label = uiString(R.string.l10n_notifications_settings_screen_buzz_on_incoming_calls_625804a1),
- )
+ NoopSwitch(enabled, onMasterChange, true, "Wrist alerts")
}
- 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),
- )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ 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.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)
}
- 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,
- )
+ ActionPill("Test buzz", Icons.Filled.GraphicEq, connected && encryptedBond, 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),
- )
- }
+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)
+ DividerLine()
+ ToggleRow("VoIP calls", "Best-effort detection for supported calling apps.", voipEnabled, master, onVoip)
+ DividerLine()
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ 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)
}
- .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)
+ PatternPicker(pattern, master, onPattern)
+ ActionPill("Test", Icons.Filled.GraphicEq, commandReady && master, onTest)
+ }
}
}
}
-// MARK: - Category card (rows of apps, dimmed/disabled when master is off)
-
@Composable
-private fun CategoryCard(
- category: NotifCategory,
- apps: List,
- masterEnabled: Boolean,
- bonded: 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(
- app = app,
- enabled = enabledState[app.id] ?: false,
- pattern = patternState[app.id] ?: app.category.defaultPattern,
- interactive = masterEnabled,
- bonded = bonded,
- onToggle = { onToggle(app, it) },
- onPattern = { onPattern(app, it) },
- onTest = { onTest(app) },
- )
- if (idx < apps.size - 1) RowDivider()
+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 { 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, 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 {
+ filtered.groupBy { it.category }.forEach { (category, apps) ->
+ Text(category.title, style = NoopType.caption, color = Palette.accent)
+ apps.forEachIndexed { index, 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()
+ }
}
}
}
}
@Composable
-private fun AppRow(
- app: NotifApp,
- enabled: Boolean,
- pattern: BuzzPattern,
- interactive: Boolean,
- bonded: Boolean,
- onToggle: (Boolean) -> Unit,
- onPattern: (BuzzPattern) -> Unit,
- onTest: () -> Unit,
-) {
+private fun AppAlertRow(app: NotifApp, enabled: Boolean, pattern: BuzzPattern, interactive: Boolean, onToggle: (Boolean) -> Unit, onPattern: (BuzzPattern) -> Unit, onTest: () -> Unit) {
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),
+ 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(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),
- 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)) {
+ 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), 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, 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(enabled, onToggle, interactive, "${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,
-) {
- 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),
- )
- Text(pattern.label, style = NoopType.caption, color = Palette.textSecondary)
- }
- DropdownMenu(
- expanded = expanded,
- onDismissRequest = { expanded = false },
- modifier = Modifier.background(Palette.surfaceOverlay),
- ) {
- 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
- },
- )
- }
- }
+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()
+ 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)
}
}
-// MARK: - Test buttons
+@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(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)) } }
+ }
+}
@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 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(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.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 PillButton(label: String, icon: ImageVector, enabled: Boolean, onClick: () -> Unit) {
- val shape = RoundedCornerShape(50)
- val tint = if (enabled) Palette.accent else Palette.textTertiary
+private fun StatusRow(icon: ImageVector, title: String, detail: String, positive: Boolean) {
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),
+ modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
- Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(14.dp))
- Text(label, style = NoopType.caption, color = tint)
+ Icon(icon, null, tint = if (positive) Palette.accent else Palette.textTertiary, modifier = Modifier.size(18.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)
+ }
+ StatusChip(Icons.Filled.CheckCircle, if (positive) "OK" else "WAIT", positive)
}
}
-// MARK: - Time chip (TimePickerDialog → HH:mm). Reused by the Automations smart-alarm time too.
+private fun notificationAccessGranted(context: Context): Boolean {
+ val enabled = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners") ?: return false
+ return enabled.split(":").any { component -> component.startsWith(context.packageName) }
+}
-@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,
- 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),
- )
- }
+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.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()
}
}
}
-// 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)
- }
- content()
- }
+private fun ToggleRow(label: String, help: String, checked: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) {
+ 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)
}
}
-// MARK: - Label + help + switch row (mirrors FormToggleRow)
-
@Composable
-private fun FormToggleRow(
- label: String,
- help: String,
- checked: Boolean,
- enabled: Boolean = true,
- onChange: (Boolean) -> Unit,
-) {
+private fun StatusChip(icon: ImageVector, text: String, positive: Boolean) {
+ val tint = if (positive) Palette.accent else Palette.textTertiary
Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(48.dp),
+ 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),
) {
- 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)
+ Icon(icon, null, tint = tint, modifier = Modifier.size(13.dp))
+ Text(text, style = NoopType.caption, color = tint)
}
}
-// MARK: - Shared bits
-
@Composable
-private fun NoopSwitch(
- checked: Boolean,
- onChange: (Boolean) -> Unit,
- enabled: Boolean = true,
- 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,
- ),
- modifier = Modifier.semantics { contentDescription = label },
- )
+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, null, tint = tint, modifier = Modifier.size(13.dp))
+ Text(label, style = NoopType.caption, color = tint)
+ }
}
@Composable
-private fun RowDivider() {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(1.dp)
- .padding(vertical = 4.dp)
- .background(Palette.hairline),
- )
+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 })
+ }
+ }
+ }
}
-/** Apply a uniform alpha to a subtree (dims disabled category content). */
-private fun Modifier.alphaIf(value: Float): Modifier = this.alpha(value)
+@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))
+}
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),
+ )
+ }
+ }
+ }
+ }
+}
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..fc31d52f96
--- /dev/null
+++ b/docs/NOOP_NEXT.md
@@ -0,0 +1,51 @@
+# NOOP Next — call haptics and notification UX
+
+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.
+
+## Call-alert behaviour
+
+- 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.
+
+## Current UI
+
+The Android notification screen already uses the vNext control-centre layout:
+
+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.
+
+The important path is deliberately visible: **phone event → NOOP detects → policy allows → encrypted WHOOP link → haptic delivered**.
+
+## WHOOP 5/MG research
+
+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 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.
+
+## Reliability acceptance tests
+
+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.
+
+## Privacy
+
+Notification text, phone numbers, and call logs remain on-device. The call-alert path requires no cloud relay.