From 86d0eca615e6a5788b561b7b35fc6dbea208d646 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Fri, 23 Oct 2026 17:12:07 -0700 Subject: [PATCH 1/3] feat(island): toggle system icon for bubble queue and hub --- .../data/repository/SettingsRepository.kt | 4 ++ .../domain/model/ActiveNotificationAlert.kt | 2 + .../notifications/NotificationsPlugin.kt | 45 ++++++++++++++----- .../island/service/IslandCoordinator.kt | 2 +- .../island/ui/components/IslandComponents.kt | 36 +++++++++++++++ .../services/NotificationListener.kt | 6 +++ .../features/display/IslandSubSettingsUI.kt | 12 +++++ .../essentials/viewmodels/MainViewModel.kt | 7 +++ app/src/main/res/values/strings.xml | 2 + 9 files changed, 104 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index be508fa05..5552a23ec 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -546,6 +546,7 @@ class SettingsRepository( const val KEY_ISLAND_BATTERY_HIDE_LOCKED = "island_battery_hide_locked" const val KEY_ISLAND_TIMEOUT_MS = "island_timeout_ms" const val KEY_ISLAND_SHOW_GLOW = "island_show_glow" + const val KEY_ISLAND_MONOCHROME = "island_monochrome" const val KEY_ISLAND_EXPANDED_WIDTH = "island_expanded_width" const val KEY_ISLAND_EXPANDED_ROUNDNESS = "island_expanded_roundness" const val KEY_ISLAND_EXPANDED_PADDING = "island_expanded_padding" @@ -4093,6 +4094,9 @@ class SettingsRepository( fun isIslandShowGlowEnabled(): Boolean = getBoolean(KEY_ISLAND_SHOW_GLOW, true) fun setIslandShowGlowEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SHOW_GLOW, enabled) + fun isIslandMonochromeEnabled(): Boolean = getBoolean(KEY_ISLAND_MONOCHROME, false) + fun setIslandMonochromeEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_MONOCHROME, enabled) + fun getIslandExpandedWidth(): Float = getFloat(KEY_ISLAND_EXPANDED_WIDTH, 360f) fun setIslandExpandedWidth(value: Float) = putFloat(KEY_ISLAND_EXPANDED_WIDTH, value) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/ActiveNotificationAlert.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/ActiveNotificationAlert.kt index 1ec301ff1..4038351d9 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/ActiveNotificationAlert.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/ActiveNotificationAlert.kt @@ -32,5 +32,7 @@ data class ActiveNotificationAlert( val ongoing: Boolean = false, val isConversation: Boolean = false, val hubOnly: Boolean = false, + // The status bar glyph (Notification.smallIcon), used by the monochrome island + val smallIcon: Bitmap? = null, ) diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt index 4d063ce2a..3d6de90f7 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt @@ -9,6 +9,9 @@ import android.app.KeyguardManager import android.content.Context import android.os.PowerManager import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.runtime.Composable +import android.graphics.Bitmap import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository @@ -32,6 +35,7 @@ import com.sameerasw.essentials.island.plugins.soften import com.sameerasw.essentials.island.ui.IslandStackBubble import com.sameerasw.essentials.island.ui.IslandStackOverlap import com.sameerasw.essentials.island.ui.components.IslandBitmap +import com.sameerasw.essentials.island.ui.components.NotificationGlyph import com.sameerasw.essentials.island.ui.components.IslandIcon import com.sameerasw.essentials.services.NotificationListener import com.sameerasw.essentials.utils.HapticUtil @@ -46,6 +50,7 @@ class NotificationsPlugin : BaseIslandPlugin() { SettingsRepository.KEY_ISLAND_NOTIF_QUEUE_INLINE, SettingsRepository.KEY_ISLAND_LINE_STAGE_ENABLED, SettingsRepository.KEY_ISLAND_SHOW_GLOW, + SettingsRepository.KEY_ISLAND_MONOCHROME, SettingsRepository.KEY_ISLAND_NOTIF_QUEUE, SettingsRepository.KEY_ISLAND_NOTIF_SKIP_SILENT, SettingsRepository.KEY_ISLAND_NOTIF_FOLLOW_DND, @@ -540,7 +545,7 @@ class NotificationsPlugin : BaseIslandPlugin() { if (!entry.grouped) return stackIconFor(entry.lead, current = false, highlight = highlight) val icon = entry.lead.appIcon ?: entry.lead.icon return StackIcon(entry.key, onSelect = { select(entry.lead.key) }, highlight = highlight) { size -> - IslandBitmap(icon, size, circle = true, fallbackRes = R.drawable.rounded_notifications_unread_24) + AlertIcon(entry.lead, icon, size, circle = true) } } @@ -715,16 +720,16 @@ class NotificationsPlugin : BaseIslandPlugin() { compact = buildList { add( CompactCell("notif.icon") { - IslandBitmap(icon, 22.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) + AlertIcon(alert, icon, 22.dp) }, ) val appIcon = alert.appIcon - if (!minimal() && alert.chatIcon != null && appIcon != null) { + if (!minimal() && !monochrome() && alert.chatIcon != null && appIcon != null) { add(CompactCell("notif.app", soloOnly = true) { IslandBitmap(appIcon, 22.dp, circle = true) }) } }, line = LineContent( - icon = { IslandBitmap(icon, 24.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) }, + icon = { AlertIcon(alert, icon, 24.dp) }, start = sender, end = message, condensed = condensedLine(), @@ -772,24 +777,24 @@ class NotificationsPlugin : BaseIslandPlugin() { compact = buildList { add( CompactCell("notif.icon") { - IslandBitmap(icon, 22.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) + AlertIcon(alert, icon, 22.dp) }, ) val appIcon = alert.appIcon - if (!minimal() && !hideChat && alert.chatIcon != null && appIcon != null) { + if (!minimal() && !monochrome() && !hideChat && alert.chatIcon != null && appIcon != null) { add(CompactCell("notif.app", soloOnly = true) { IslandBitmap(appIcon, 22.dp, circle = true) }) } }, line = if (isOtp(alert)) { LineContent( - icon = { IslandBitmap(alert.appIcon ?: alert.icon, 24.dp, fallbackRes = R.drawable.rounded_lock_24) }, + icon = { AlertIcon(alert, alert.appIcon ?: alert.icon, 24.dp, fallbackRes = R.drawable.rounded_lock_24) }, start = "${appNameFor(context, alert)} ยท ${context.getString(R.string.island_otp_label)}", end = alert.otpCode?.let(::maskOtp).orEmpty(), endSlot = { IslandIcon(R.drawable.rounded_lock_24, size = 18.dp) }, ) } else { LineContent( - icon = { IslandBitmap(icon, 24.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) }, + icon = { AlertIcon(alert, icon, 24.dp) }, start = appNameFor(context, alert), end = "", condensed = condensedLine(), @@ -823,11 +828,11 @@ class NotificationsPlugin : BaseIslandPlugin() { placement = CompactPlacement.Dynamic, compact = listOf( CompactCell("notif.icon") { - IslandBitmap(icon, 22.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) + AlertIcon(alert, icon, 22.dp) }, ), line = LineContent( - icon = { IslandBitmap(icon, 24.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) }, + icon = { AlertIcon(alert, icon, 24.dp) }, start = sender, end = formatOtp(code), endSlot = { IslandIcon(R.drawable.rounded_content_copy_24, size = 18.dp) }, @@ -913,7 +918,25 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun stackIconFor(alert: ActiveNotificationAlert, current: Boolean, highlight: Boolean = false): StackIcon { val icon = if (concealed() && hidesChatPictures()) alert.appIcon ?: alert.icon else alert.chatIcon ?: alert.appIcon ?: alert.icon return StackIcon(alert.key, current = current, onSelect = { select(alert.key) }, highlight = highlight) { size -> - IslandBitmap(icon, size, circle = true, fallbackRes = R.drawable.rounded_notifications_unread_24) + AlertIcon(alert, icon, size, circle = true) + } + } + + private fun monochrome() = settings.isIslandMonochromeEnabled() + + // Monochrome swaps the picture for the app's notification glyph on the island's softened accent + @Composable + private fun AlertIcon( + alert: ActiveNotificationAlert, + bitmap: Bitmap?, + size: Dp, + circle: Boolean = false, + fallbackRes: Int = R.drawable.rounded_notifications_unread_24, + ) { + if (monochrome()) { + NotificationGlyph(alert.smallIcon, alert.appColor?.let { Color(soften(it)) }, size, fallbackRes = fallbackRes) + } else { + IslandBitmap(bitmap, size, circle = circle, fallbackRes = fallbackRes) } } diff --git a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt index 9f5fd291d..fcf26bc0b 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt @@ -728,7 +728,6 @@ class IslandCoordinator( controller.maxCells = settings.getIslandMaxItems() * 2 controller.balanceCompact = settings.isIslandBalanceCompactEnabled() controller.secondaryBubbleEnabled = settings.isIslandSecondaryBubbleEnabled() - controller.secondaryBubbleOverlap = settings.getIslandNotifQueueStyle() == SettingsRepository.ISLAND_NOTIF_QUEUE_STYLE_OVERLAP controller.relayout() controller.expandedTimeoutMs = settings.getIslandExpandedTimeoutMs() applyPreviewRing() @@ -822,6 +821,7 @@ class IslandCoordinator( "alarm" to SettingsRepository.KEY_ISLAND_ALARM_LAUNCHER_ONLY, ) val CONFIG_KEYS = setOf( + SettingsRepository.KEY_ISLAND_MONOCHROME, SettingsRepository.KEY_ISLAND_NOTIF_QUEUE_STYLE, SettingsRepository.KEY_ISLAND_NOTIF_QUEUE_INLINE, SettingsRepository.KEY_ISLAND_USE_AUTO_DETECT, diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt index 73e380be7..ea3d84070 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt @@ -52,9 +52,45 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.material3.Text import androidx.compose.foundation.layout.Row import com.sameerasw.essentials.island.ui.IslandMotion +import androidx.compose.foundation.background +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.ColorFilter import kotlinx.coroutines.delay import kotlin.random.Random +// Monochrome stand-in for a notification picture: the app's status bar glyph on its accent color +@Composable +fun NotificationGlyph( + smallIcon: Bitmap?, + color: Color?, + size: Dp, + modifier: Modifier = Modifier, + fallbackRes: Int, +) { + val background = color ?: MaterialTheme.colorScheme.primary + val glyph = if (background.luminance() > 0.6f) Color.Black else Color.White + Box( + modifier + .sizeIn(maxWidth = size, maxHeight = size) + .aspectRatio(1f, matchHeightConstraintsFirst = true) + .clip(CircleShape) + .background(background), + contentAlignment = Alignment.Center, + ) { + if (smallIcon != null) { + Image( + bitmap = remember(smallIcon) { smallIcon.asImageBitmap() }, + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(glyph), + modifier = Modifier.fillMaxSize(0.6f), + ) + } else { + Icon(painterResource(fallbackRes), null, tint = glyph, modifier = Modifier.fillMaxSize(0.6f)) + } + } +} + @Composable fun IslandBitmap( bitmap: Bitmap?, diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt index a705aae64..195018f1a 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -2315,6 +2315,11 @@ class NotificationListener : NotificationListenerService() { bitmap = appIcon } + var smallIconBitmap: Bitmap? = null + try { + notif.smallIcon?.loadDrawable(this)?.let { smallIconBitmap = AppUtil.drawableToBitmap(it) } + } catch (_: Exception) {} + var appColor: Int? = null if (notif.color != 0 && notif.color != android.graphics.Color.TRANSPARENT) { appColor = notif.color @@ -2365,6 +2370,7 @@ class NotificationListener : NotificationListenerService() { appIcon = appIcon, actions = actionList, chatIcon = chatIcon, + smallIcon = smallIconBitmap, ) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt index 3183dadf2..eca6fe760 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt @@ -1257,6 +1257,18 @@ fun IslandNotificationSettingsUI( modifier = Modifier.highlight(highlightSetting == "island_catch_up_timeout"), ) } + + IconToggleItem( + iconRes = R.drawable.rounded_invert_colors_24, + title = stringResource(R.string.island_monochrome_title), + description = stringResource(R.string.island_monochrome_desc), + isChecked = viewModel.isIslandMonochrome.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandMonochrome(checked) + }, + modifier = Modifier.highlight(highlightSetting == "island_monochrome"), + ) } if (showOtpPermissions) { diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index e99c0317a..726456341 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -300,6 +300,7 @@ class MainViewModel : ViewModel() { val isIslandOtpEnabled = mutableStateOf(false) val islandOtpTimeoutMs = mutableLongStateOf(60_000L) val isIslandShowGlow = mutableStateOf(true) + val isIslandMonochrome = mutableStateOf(false) val isIslandBorderOutline = mutableStateOf(false) val islandBorderOutlineColor = mutableStateOf(SettingsRepository.ISLAND_BORDER_OUTLINE_DEFAULT_COLOR) val isIslandBorderOutlineDynamic = mutableStateOf(false) @@ -2579,6 +2580,7 @@ class MainViewModel : ViewModel() { isIslandOtpEnabled.value = settingsRepository.isIslandOtpEnabled() islandOtpTimeoutMs.longValue = settingsRepository.getIslandOtpTimeoutMs() isIslandShowGlow.value = settingsRepository.isIslandShowGlowEnabled() + isIslandMonochrome.value = settingsRepository.isIslandMonochromeEnabled() isIslandBorderOutline.value = settingsRepository.isIslandBorderOutlineEnabled() islandBorderOutlineColor.value = settingsRepository.getIslandBorderOutlineColor() isIslandBorderOutlineDynamic.value = settingsRepository.isIslandBorderOutlineDynamicEnabled() @@ -6165,6 +6167,11 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandShowGlowEnabled(enabled) } + fun setIslandMonochrome(enabled: Boolean) { + isIslandMonochrome.value = enabled + settingsRepository.setIslandMonochromeEnabled(enabled) + } + fun setIslandBorderOutline(enabled: Boolean) { isIslandBorderOutline.value = enabled settingsRepository.setIslandBorderOutlineEnabled(enabled) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f50484bf..7d0ad6a59 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3229,6 +3229,8 @@ Glow spread Pulse duration App based color glow + App icon bubble + Show the app\'s notification icon on its app color instead of the picture or content Expanded width Camera gap Width when long pressed to expand From 0b539b4f0dea9d8e9cece1941af0100620c31658 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Fri, 23 Oct 2026 20:27:26 -0700 Subject: [PATCH 2/3] feat(island): handle contrast difference on icons --- .../essentials/island/ui/components/IslandComponents.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt index ea3d84070..ae76129b5 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt @@ -46,6 +46,7 @@ import com.sameerasw.essentials.island.ui.IslandTextStyles import androidx.compose.ui.unit.sp import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawBehind @@ -68,7 +69,7 @@ fun NotificationGlyph( fallbackRes: Int, ) { val background = color ?: MaterialTheme.colorScheme.primary - val glyph = if (background.luminance() > 0.6f) Color.Black else Color.White + val glyph = lerp(background, if (background.luminance() > 0.179f) Color.Black else Color.White, 0.8f) Box( modifier .sizeIn(maxWidth = size, maxHeight = size) From eb225d759843135c6cff19a9c4d09cd9835b7b0a Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Fri, 23 Oct 2026 21:53:59 -0700 Subject: [PATCH 3/3] feat: update island pulse shadow options and rendering --- .../data/repository/SettingsRepository.kt | 22 ++++---- .../island/service/IslandCoordinator.kt | 14 ++--- .../essentials/island/ui/IslandLayoutSpec.kt | 7 +-- .../essentials/island/ui/IslandRoot.kt | 36 ++++++------- .../IslandPulseShadowOptionsBottomSheet.kt | 53 ++++++++++++------- .../essentials/viewmodels/MainViewModel.kt | 29 ++++++---- app/src/main/res/values/strings.xml | 7 +-- 7 files changed, 96 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 5552a23ec..cb545b5ce 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -711,9 +711,10 @@ class SettingsRepository( const val KEY_ISLAND_BORDER_OUTLINE_THICKNESS = "island_border_outline_thickness" const val KEY_ISLAND_BORDER_OUTLINE_HIDE_EXPANDED = "island_border_outline_hide_expanded" const val KEY_ISLAND_PULSE_SHADOW_ON_NOTIFICATION = "island_pulse_shadow_on_notification" - const val KEY_ISLAND_PULSE_SHADOW_SIZE = "island_pulse_shadow_size" - const val KEY_ISLAND_PULSE_SHADOW_Y_SHIFT = "island_pulse_shadow_y_shift" - const val KEY_ISLAND_PULSE_SHADOW_SPREAD = "island_pulse_shadow_spread" + const val KEY_ISLAND_PULSE_SHADOW_BLUR = "island_pulse_shadow_blur" + const val KEY_ISLAND_PULSE_SHADOW_SPREAD_DP = "island_pulse_shadow_spread_dp" + const val KEY_ISLAND_PULSE_SHADOW_OFFSET_Y = "island_pulse_shadow_offset_y" + const val KEY_ISLAND_PULSE_SHADOW_OPACITY = "island_pulse_shadow_opacity" const val KEY_ISLAND_PULSE_SHADOW_DURATION_MS = "island_pulse_shadow_duration_ms" const val ISLAND_BORDER_OUTLINE_DEFAULT_COLOR = "#202020" @@ -4079,14 +4080,17 @@ class SettingsRepository( fun isIslandPulseShadowEnabled(): Boolean = getBoolean(KEY_ISLAND_PULSE_SHADOW_ON_NOTIFICATION, false) fun setIslandPulseShadowEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_PULSE_SHADOW_ON_NOTIFICATION, enabled) - fun getIslandPulseShadowSize(): Float = getFloat(KEY_ISLAND_PULSE_SHADOW_SIZE, 0.5f) - fun setIslandPulseShadowSize(value: Float) = putFloat(KEY_ISLAND_PULSE_SHADOW_SIZE, value) + fun getIslandPulseShadowBlur(): Float = getFloat(KEY_ISLAND_PULSE_SHADOW_BLUR, 20f) + fun setIslandPulseShadowBlur(value: Float) = putFloat(KEY_ISLAND_PULSE_SHADOW_BLUR, value) - fun getIslandPulseShadowYShift(): Float = getFloat(KEY_ISLAND_PULSE_SHADOW_Y_SHIFT, 0.35f) - fun setIslandPulseShadowYShift(value: Float) = putFloat(KEY_ISLAND_PULSE_SHADOW_Y_SHIFT, value) + fun getIslandPulseShadowSpread(): Float = getFloat(KEY_ISLAND_PULSE_SHADOW_SPREAD_DP, 6f) + fun setIslandPulseShadowSpread(value: Float) = putFloat(KEY_ISLAND_PULSE_SHADOW_SPREAD_DP, value) - fun getIslandPulseShadowSpread(): Float = getFloat(KEY_ISLAND_PULSE_SHADOW_SPREAD, 2f) - fun setIslandPulseShadowSpread(value: Float) = putFloat(KEY_ISLAND_PULSE_SHADOW_SPREAD, value) + fun getIslandPulseShadowOffsetY(): Float = getFloat(KEY_ISLAND_PULSE_SHADOW_OFFSET_Y, 1f) + fun setIslandPulseShadowOffsetY(value: Float) = putFloat(KEY_ISLAND_PULSE_SHADOW_OFFSET_Y, value) + + fun getIslandPulseShadowOpacity(): Float = getFloat(KEY_ISLAND_PULSE_SHADOW_OPACITY, 0.35f) + fun setIslandPulseShadowOpacity(value: Float) = putFloat(KEY_ISLAND_PULSE_SHADOW_OPACITY, value) fun getIslandPulseShadowDurationMs(): Float = getFloat(KEY_ISLAND_PULSE_SHADOW_DURATION_MS, 1450f) fun setIslandPulseShadowDurationMs(value: Float) = putFloat(KEY_ISLAND_PULSE_SHADOW_DURATION_MS, value) diff --git a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt index fcf26bc0b..c13e94359 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt @@ -702,9 +702,10 @@ class IslandCoordinator( outlineThickness = settings.getIslandBorderOutlineThickness().coerceIn(1f, 5f).dp, outlineHiddenWhenExpanded = settings.isIslandBorderOutlineHiddenWhenExpanded(), pulseShadow = settings.isIslandPulseShadowEnabled(), - pulseSize = settings.getIslandPulseShadowSize().coerceIn(0.2f, 1f), - pulseYShift = settings.getIslandPulseShadowYShift().coerceIn(0f, 1f), - pulseSpread = settings.getIslandPulseShadowSpread().coerceIn(1f, 4f), + pulseBlur = settings.getIslandPulseShadowBlur().coerceIn(0f, 48f).dp, + pulseSpread = settings.getIslandPulseShadowSpread().coerceIn(0f, 24f).dp, + pulseOffsetY = settings.getIslandPulseShadowOffsetY().coerceIn(-8f, 16f).dp, + pulseOpacity = settings.getIslandPulseShadowOpacity().coerceIn(0.1f, 1f), pulseDurationMs = settings.getIslandPulseShadowDurationMs().coerceIn(300f, 4000f).toInt(), queueStyle = when (settings.getIslandNotifQueueStyle()) { SettingsRepository.ISLAND_NOTIF_QUEUE_STYLE_OVERLAP -> QueueStyle.Overlap @@ -841,9 +842,10 @@ class IslandCoordinator( SettingsRepository.KEY_ISLAND_BORDER_OUTLINE_THICKNESS, SettingsRepository.KEY_ISLAND_BORDER_OUTLINE_HIDE_EXPANDED, SettingsRepository.KEY_ISLAND_PULSE_SHADOW_ON_NOTIFICATION, - SettingsRepository.KEY_ISLAND_PULSE_SHADOW_SIZE, - SettingsRepository.KEY_ISLAND_PULSE_SHADOW_Y_SHIFT, - SettingsRepository.KEY_ISLAND_PULSE_SHADOW_SPREAD, + SettingsRepository.KEY_ISLAND_PULSE_SHADOW_BLUR, + SettingsRepository.KEY_ISLAND_PULSE_SHADOW_SPREAD_DP, + SettingsRepository.KEY_ISLAND_PULSE_SHADOW_OFFSET_Y, + SettingsRepository.KEY_ISLAND_PULSE_SHADOW_OPACITY, SettingsRepository.KEY_ISLAND_PULSE_SHADOW_DURATION_MS, SettingsRepository.KEY_ISLAND_EXPANDED_ROUNDNESS, SettingsRepository.KEY_ISLAND_EXPANDED_PADDING, diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt index 36dc9c7ed..046db4583 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt @@ -34,9 +34,10 @@ data class IslandLayoutSpec( val outlineThickness: Dp = 1.dp, val outlineHiddenWhenExpanded: Boolean = false, val pulseShadow: Boolean = false, - val pulseSize: Float = 0.5f, - val pulseYShift: Float = 0.35f, - val pulseSpread: Float = 2f, + val pulseBlur: Dp = 20.dp, + val pulseSpread: Dp = 6.dp, + val pulseOffsetY: Dp = 1.dp, + val pulseOpacity: Float = 0.35f, val pulseDurationMs: Int = 1450, val queueStyle: QueueStyle = QueueStyle.Stack, val queueInline: Boolean = false, diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt index a54c5eea0..658c436ac 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt @@ -35,8 +35,6 @@ import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.drawscope.scale import com.sameerasw.essentials.R import androidx.compose.ui.unit.Dp import androidx.compose.ui.res.stringResource @@ -85,6 +83,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.animation.animateColorAsState import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import android.graphics.BlurMaskFilter import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker @@ -223,6 +224,10 @@ private fun IslandRootContent( ) val pulseAccent = state.items[state.focusedKey]?.accent + val pulsePaint = remember { android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) } + val pulseBlur = remember(spec.pulseBlur, density) { + with(density) { spec.pulseBlur.toPx() }.takeIf { it > 0f }?.let { BlurMaskFilter(it * 0.866f, BlurMaskFilter.Blur.NORMAL) } + } val dynamicOutline: Color? = if (spec.outlineDynamic) { val focused = state.items[state.focusedKey] (focused?.outlineAccent ?: focused?.accent) @@ -241,7 +246,7 @@ private fun IslandRootContent( pulse.snapTo(0f) if (!spec.pulseShadow || state.focusedKey == null) return@LaunchedEffect pulse.animateTo(1f, tween(250, easing = LinearEasing)) - pulse.animateTo(0f, tween(1200, easing = LinearEasing)) + pulse.animateTo(0f, tween((spec.pulseDurationMs - 250).coerceAtLeast(100), easing = LinearEasing)) } val minSurfaceWidth = with(density) { spec.cameraDiameter.roundToPx() } val minSurfaceHeight = with(density) { spec.compactHeight.roundToPx() } @@ -872,23 +877,14 @@ private fun IslandRootContent( .drawBehind { val p = pulse.value if (pulseAccent == null || p <= 0f || !visible) return@drawBehind - val w = size.width * spec.pulseSize - val h = size.height * spec.pulseSize - val center = Offset(size.width / 2f, size.height / 2f + h * spec.pulseYShift) - val radius = h / 2f * spec.pulseSpread - scale(scaleX = w / h, scaleY = 1f, pivot = center) { - drawCircle( - brush = Brush.radialGradient( - 0f to pulseAccent.copy(alpha = 0.95f * p), - 0.45f to pulseAccent.copy(alpha = 0.55f * p), - 1f to Color.Transparent, - center = center, - radius = radius, - ), - radius = radius, - center = center, - ) - } + val spread = spec.pulseSpread.toPx() + val dy = spec.pulseOffsetY.toPx() + val r = surfaceCornerPx(size.height, compactHeightPx, expandedCornerPx) + spread + pulsePaint.color = pulseAccent.copy(alpha = spec.pulseOpacity * p).toArgb() + pulsePaint.maskFilter = pulseBlur + drawContext.canvas.nativeCanvas.drawRoundRect( + -spread, -spread + dy, size.width + spread, size.height + spread + dy, r, r, pulsePaint, + ) } .graphicsLayer { alpha = (if (visible) 1f else 0f) * (1f - if (bubbleOnlyLayout) catchUpAnim.value.coerceIn(0f, 1f) else 0f) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandPulseShadowOptionsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandPulseShadowOptionsBottomSheet.kt index 592d165ba..a0f3e4580 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandPulseShadowOptionsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandPulseShadowOptionsBottomSheet.kt @@ -18,7 +18,7 @@ import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.sheets.EssentialsBottomSheet import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.viewmodels.MainViewModel -import java.util.Locale +import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -45,42 +45,55 @@ fun IslandPulseShadowOptionsBottomSheet( RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { ConfigSliderItem( - title = stringResource(R.string.island_pulse_shadow_size_title), - value = viewModel.islandPulseShadowSize.floatValue, + title = stringResource(R.string.island_pulse_shadow_blur_title), + value = viewModel.islandPulseShadowBlur.floatValue, onValueChange = { HapticUtil.performUIHaptic(view) - viewModel.setIslandPulseShadowSize(it) + viewModel.setIslandPulseShadowBlur(it) }, - valueRange = 0.2f..1f, - increment = 0.05f, + valueRange = 0f..48f, + increment = 1f, + iconRes = R.drawable.rounded_blur_on_24, + valueFormatter = { "${it.toInt()} dp" }, + ) + + ConfigSliderItem( + title = stringResource(R.string.island_pulse_shadow_spread_title), + value = viewModel.islandPulseShadowSpread.floatValue, + onValueChange = { + HapticUtil.performUIHaptic(view) + viewModel.setIslandPulseShadowSpread(it) + }, + valueRange = 0f..24f, + increment = 1f, iconRes = R.drawable.rounded_magnify_fullscreen_24, - valueFormatter = { "${(it * 100).toInt()}%" }, + valueFormatter = { "${it.toInt()} dp" }, ) ConfigSliderItem( - title = stringResource(R.string.island_pulse_shadow_y_shift_title), - value = viewModel.islandPulseShadowYShift.floatValue, + title = stringResource(R.string.island_pulse_shadow_offset_y_title), + value = viewModel.islandPulseShadowOffsetY.floatValue, onValueChange = { HapticUtil.performUIHaptic(view) - viewModel.setIslandPulseShadowYShift(it) + viewModel.setIslandPulseShadowOffsetY(it) }, - valueRange = 0f..1f, - increment = 0.05f, + valueRange = -8f..16f, + increment = 1f, iconRes = R.drawable.rounded_screenshot_region_24, - valueFormatter = { "${(it * 100).toInt()}%" }, + valueFormatter = { "${it.toInt()} dp" }, ) ConfigSliderItem( - title = stringResource(R.string.island_pulse_shadow_spread_title), - value = viewModel.islandPulseShadowSpread.floatValue, + title = stringResource(R.string.island_pulse_shadow_opacity_title), + value = viewModel.islandPulseShadowOpacity.floatValue, onValueChange = { HapticUtil.performUIHaptic(view) - viewModel.setIslandPulseShadowSpread(it) + viewModel.setIslandPulseShadowOpacity(it) }, - valueRange = 1f..4f, - increment = 0.1f, - iconRes = R.drawable.rounded_blur_on_24, - valueFormatter = { String.format(Locale.US, "%.1fx", it) }, + valueRange = 0.1f..1f, + increment = 0.02f, + iconRes = R.drawable.rounded_invert_colors_24, + valueFormatter = { "${(it * 100).roundToInt()}%" }, ) ConfigSliderItem( diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 726456341..070c2e14e 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -307,9 +307,10 @@ class MainViewModel : ViewModel() { val islandBorderOutlineThickness = mutableFloatStateOf(1f) val isIslandBorderOutlineHiddenWhenExpanded = mutableStateOf(false) val isIslandPulseShadow = mutableStateOf(false) - val islandPulseShadowSize = mutableFloatStateOf(0.5f) - val islandPulseShadowYShift = mutableFloatStateOf(0.35f) - val islandPulseShadowSpread = mutableFloatStateOf(2f) + val islandPulseShadowBlur = mutableFloatStateOf(20f) + val islandPulseShadowSpread = mutableFloatStateOf(6f) + val islandPulseShadowOffsetY = mutableFloatStateOf(1f) + val islandPulseShadowOpacity = mutableFloatStateOf(0.35f) val islandPulseShadowDurationMs = mutableFloatStateOf(1450f) val isIslandShowMedia = mutableStateOf(true) val isIslandShowCalendar = mutableStateOf(false) @@ -2587,9 +2588,10 @@ class MainViewModel : ViewModel() { islandBorderOutlineThickness.floatValue = settingsRepository.getIslandBorderOutlineThickness() isIslandBorderOutlineHiddenWhenExpanded.value = settingsRepository.isIslandBorderOutlineHiddenWhenExpanded() isIslandPulseShadow.value = settingsRepository.isIslandPulseShadowEnabled() - islandPulseShadowSize.floatValue = settingsRepository.getIslandPulseShadowSize() - islandPulseShadowYShift.floatValue = settingsRepository.getIslandPulseShadowYShift() + islandPulseShadowBlur.floatValue = settingsRepository.getIslandPulseShadowBlur() islandPulseShadowSpread.floatValue = settingsRepository.getIslandPulseShadowSpread() + islandPulseShadowOffsetY.floatValue = settingsRepository.getIslandPulseShadowOffsetY() + islandPulseShadowOpacity.floatValue = settingsRepository.getIslandPulseShadowOpacity() islandPulseShadowDurationMs.floatValue = settingsRepository.getIslandPulseShadowDurationMs() isIslandShowMedia.value = settingsRepository.isIslandShowMediaEnabled() isIslandShowCalendar.value = settingsRepository.isIslandShowCalendarEnabled() @@ -6202,14 +6204,19 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandPulseShadowEnabled(enabled) } - fun setIslandPulseShadowSize(value: Float) { - islandPulseShadowSize.floatValue = value - settingsRepository.setIslandPulseShadowSize(value) + fun setIslandPulseShadowBlur(value: Float) { + islandPulseShadowBlur.floatValue = value + settingsRepository.setIslandPulseShadowBlur(value) } - fun setIslandPulseShadowYShift(value: Float) { - islandPulseShadowYShift.floatValue = value - settingsRepository.setIslandPulseShadowYShift(value) + fun setIslandPulseShadowOffsetY(value: Float) { + islandPulseShadowOffsetY.floatValue = value + settingsRepository.setIslandPulseShadowOffsetY(value) + } + + fun setIslandPulseShadowOpacity(value: Float) { + islandPulseShadowOpacity.floatValue = value + settingsRepository.setIslandPulseShadowOpacity(value) } fun setIslandPulseShadowSpread(value: Float) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d0ad6a59..f02ab0971 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3224,9 +3224,10 @@ Only outline the compact and peek island Show pulse shadow on notification Briefly glows behind the island in the app color - Glow size - Vertical shift - Glow spread + Blur + Vertical offset + Spread + Opacity Pulse duration App based color glow App icon bubble