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.

![Repobeats analytics image](https://repobeats.axiom.co/api/embed/97acba228c083adca8453a1ebf15f18dad2894be.svg "Repobeats analytics image") - -### 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. - -[![Star History Chart](https://api.star-history.com/svg?repos=ryanbr/noop&type=Date)](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) }