From 4b1421f0cf70d41537e9ca0010c589e7856debab Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Sun, 11 Oct 2026 22:20:17 -0700 Subject: [PATCH 1/3] feat: add "Follow Do Not Disturb" setting for island notifications --- .../data/repository/SettingsRepository.kt | 4 ++++ .../domain/model/ActiveNotificationAlert.kt | 1 + .../essentials/domain/registry/FeatureRegistry.kt | 5 +++++ .../plugins/notifications/NotificationsPlugin.kt | 2 ++ .../essentials/services/NotificationListener.kt | 15 ++++++++++++++- .../ui/features/display/IslandSubSettingsUI.kt | 12 ++++++++++++ .../essentials/viewmodels/MainViewModel.kt | 10 ++++++++++ app/src/main/res/values/strings.xml | 2 ++ 8 files changed, 50 insertions(+), 1 deletion(-) 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 bdd277bd3..9a6835455 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 @@ -627,6 +627,7 @@ class SettingsRepository( const val KEY_ISLAND_NOTIF_KEEP_PROGRESS = "island_notif_keep_progress" const val KEY_ISLAND_NOTIF_QUEUE = "island_notif_queue" const val KEY_ISLAND_NOTIF_SKIP_SILENT = "island_notif_skip_silent" + const val KEY_ISLAND_NOTIF_FOLLOW_DND = "island_notif_follow_dnd" const val KEY_ISLAND_NOTIF_FILTER_APPS = "island_notif_filter_apps" const val KEY_ISLAND_NOTIF_FILTER_APPS_LIST = "island_notif_filter_apps_list" const val KEY_ISLAND_SHOW_NOTIFICATIONS = "island_show_notifications" @@ -4164,6 +4165,9 @@ class SettingsRepository( fun isIslandNotifSkipSilentEnabled(): Boolean = getBoolean(KEY_ISLAND_NOTIF_SKIP_SILENT, true) fun setIslandNotifSkipSilentEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_SKIP_SILENT, enabled) + fun isIslandNotifFollowDndEnabled(): Boolean = getBoolean(KEY_ISLAND_NOTIF_FOLLOW_DND, true) + fun setIslandNotifFollowDndEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_FOLLOW_DND, enabled) + fun isIslandNotifFilterAppsEnabled(): Boolean = getBoolean(KEY_ISLAND_NOTIF_FILTER_APPS, false) fun setIslandNotifFilterAppsEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_FILTER_APPS, enabled) 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 655ed8184..78250276c 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 @@ -27,5 +27,6 @@ data class ActiveNotificationAlert( val actions: List = emptyList(), val chatIcon: Bitmap? = null, val isSilent: Boolean = false, + val blockedByDnd: Boolean = false, ) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 32dfcaf44..61443cc45 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -978,6 +978,11 @@ object FeatureRegistry { R.string.island_notif_skip_silent_desc, "island_notif_skip_silent", ), + SearchSetting( + R.string.island_notif_follow_dnd_title, + R.string.island_notif_follow_dnd_desc, + "island_notif_follow_dnd", + ), SearchSetting( R.string.island_notif_keep_progress_title, R.string.island_notif_keep_progress_desc, 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 153468a0a..3b055b03c 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 @@ -42,6 +42,7 @@ class NotificationsPlugin : BaseIslandPlugin() { SettingsRepository.KEY_ISLAND_SHOW_GLOW, SettingsRepository.KEY_ISLAND_NOTIF_QUEUE, SettingsRepository.KEY_ISLAND_NOTIF_SKIP_SILENT, + SettingsRepository.KEY_ISLAND_NOTIF_FOLLOW_DND, SettingsRepository.KEY_ISLAND_NOTIF_FILTER_APPS, SettingsRepository.KEY_ISLAND_NOTIF_FILTER_APPS_LIST, SettingsRepository.KEY_ISLAND_NOTIF_TAP_TO_OPEN, @@ -162,6 +163,7 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun passesFilters(alert: ActiveNotificationAlert): Boolean { if (alert.isSilent && settings.isIslandNotifSkipSilentEnabled()) return false + if (alert.blockedByDnd && settings.isIslandNotifFollowDndEnabled()) return false if (!settings.isIslandNotifFilterAppsEnabled()) return true return settings.loadIslandNotifFilterApps().any { it.isEnabled && it.packageName == alert.packageName } } 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 053d330fd..f040e02a2 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -266,6 +266,16 @@ class NotificationListener : NotificationListenerService() { return false } + // Do Not Disturb holds this notification back; apps and conversations allowed through DND still pass. + fun isBlockedByDnd( + sbn: StatusBarNotification, + rankingMap: RankingMap? = null, + ): Boolean { + val map = rankingMap ?: currentRanking ?: return false + val ranking = Ranking() + return map.getRanking(sbn.key, ranking) && !ranking.matchesInterruptionFilter() + } + private fun populateActiveUnreadNotifications() { unreadNotifications.clear() try { @@ -1013,7 +1023,10 @@ class NotificationListener : NotificationListenerService() { } if (!isRepost && isHeadsUpNotification(sbn, rankingMap, allowSilent = true)) { - val alert = extractNotificationAlert(sbn)?.copy(isSilent = isSilentNotification(sbn, rankingMap)) + val alert = extractNotificationAlert(sbn)?.copy( + isSilent = isSilentNotification(sbn, rankingMap), + blockedByDnd = isBlockedByDnd(sbn, rankingMap), + ) if (alert != null) { notifyAlertPosted(alert) } 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 8f2a149a6..18943a640 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 @@ -935,6 +935,18 @@ fun IslandNotificationSettingsUI( modifier = Modifier.highlight(highlightSetting == "island_notif_skip_silent"), ) + IconToggleItem( + iconRes = R.drawable.rounded_do_not_disturb_on_24, + title = stringResource(R.string.island_notif_follow_dnd_title), + description = stringResource(R.string.island_notif_follow_dnd_desc), + isChecked = viewModel.isIslandNotifFollowDnd.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandNotifFollowDnd(checked) + }, + modifier = Modifier.highlight(highlightSetting == "island_notif_follow_dnd"), + ) + IconToggleItem( iconRes = R.drawable.rounded_downloading_24, title = stringResource(R.string.island_notif_keep_progress_title), 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 a7733beea..99f679edd 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -264,6 +264,7 @@ class MainViewModel : ViewModel() { val isIslandNotifKeepProgress = mutableStateOf(true) val isIslandNotifQueue = mutableStateOf(true) val isIslandNotifSkipSilent = mutableStateOf(true) + val isIslandNotifFollowDnd = mutableStateOf(true) val isIslandNotifFilterApps = mutableStateOf(false) val isIslandShowNotifications = mutableStateOf(true) val isIslandNotifTapToOpen = mutableStateOf(false) @@ -924,6 +925,9 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_ISLAND_NOTIF_SKIP_SILENT -> isIslandNotifSkipSilent.value = settingsRepository.isIslandNotifSkipSilentEnabled() + SettingsRepository.KEY_ISLAND_NOTIF_FOLLOW_DND -> + isIslandNotifFollowDnd.value = settingsRepository.isIslandNotifFollowDndEnabled() + SettingsRepository.KEY_ISLAND_NOTIF_FILTER_APPS -> isIslandNotifFilterApps.value = settingsRepository.isIslandNotifFilterAppsEnabled() @@ -2433,6 +2437,7 @@ class MainViewModel : ViewModel() { isIslandNotifKeepProgress.value = settingsRepository.isIslandNotifKeepProgressEnabled() isIslandNotifQueue.value = settingsRepository.isIslandNotifQueueEnabled() isIslandNotifSkipSilent.value = settingsRepository.isIslandNotifSkipSilentEnabled() + isIslandNotifFollowDnd.value = settingsRepository.isIslandNotifFollowDndEnabled() isIslandNotifFilterApps.value = settingsRepository.isIslandNotifFilterAppsEnabled() isIslandShowNotifications.value = settingsRepository.isIslandShowNotificationsEnabled() isIslandNotifTapToOpen.value = settingsRepository.isIslandNotifTapToOpenEnabled() @@ -5735,6 +5740,11 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandNotifSkipSilentEnabled(enabled) } + fun setIslandNotifFollowDnd(enabled: Boolean) { + isIslandNotifFollowDnd.value = enabled + settingsRepository.setIslandNotifFollowDndEnabled(enabled) + } + fun setIslandNotifFilterApps(enabled: Boolean) { isIslandNotifFilterApps.value = enabled settingsRepository.setIslandNotifFilterAppsEnabled(enabled) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 59b9ecf1b..6611da2ee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3221,6 +3221,8 @@ Lock screen Only show notifications from the apps you pick Ignore notifications that arrive without sound + Follow Do Not Disturb + Hide notifications that Do Not Disturb silences Show downloads and other ongoing progress Keep several notifications instead of replacing the current one Tap an expanded notification to open its app From 34957fd351abfa0f5ed07b68fdc4fbe2c0749905 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Mon, 12 Oct 2026 06:47:54 -0600 Subject: [PATCH 2/3] feat(island): add minimal peek style and queue view modes --- .../data/repository/SettingsRepository.kt | 23 ++- .../domain/registry/FeatureRegistry.kt | 14 +- .../essentials/island/model/IslandModels.kt | 2 + .../notifications/NotificationsPlugin.kt | 96 ++++++++-- .../island/service/IslandCoordinator.kt | 93 ++++++++++ .../island/service/IslandPreviewRequests.kt | 14 ++ .../essentials/island/ui/CompactTemplate.kt | 14 +- .../essentials/island/ui/IslandLayoutSpec.kt | 4 + .../essentials/island/ui/IslandMotion.kt | 7 + .../essentials/island/ui/IslandRoot.kt | 174 +++++++++++++----- .../essentials/island/ui/IslandStack.kt | 172 ++++++++++++++--- .../essentials/island/ui/LineTemplate.kt | 70 +++++-- .../features/display/IslandSubSettingsUI.kt | 49 ++++- ...landNotificationQueueOptionsBottomSheet.kt | 104 +++++++++++ .../essentials/viewmodels/MainViewModel.kt | 34 +++- app/src/main/res/values/strings.xml | 12 ++ .../essentials/island/CondensedLineTest.kt | 21 ++- 17 files changed, 775 insertions(+), 128 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/island/service/IslandPreviewRequests.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNotificationQueueOptionsBottomSheet.kt 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 9a6835455..bd0bd33ea 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 @@ -623,9 +623,18 @@ class SettingsRepository( const val KEY_ISLAND_MEDIA_SHOW_PREVIOUS = "island_media_show_previous" const val KEY_ISLAND_MEDIA_SHOW_LIKE = "island_media_show_like" const val KEY_ISLAND_NOTIF_COMPACT_HEADS_UP = "island_notif_compact_heads_up" + // Legacy boolean (Compact vs Full); read only to seed KEY_ISLAND_NOTIF_PEEK_STYLE. const val KEY_ISLAND_NOTIF_CONDENSED_INFO = "island_notif_condensed_info" + const val KEY_ISLAND_NOTIF_PEEK_STYLE = "island_notif_peek_style" + const val ISLAND_NOTIF_PEEK_STYLE_FULL = "full" + const val ISLAND_NOTIF_PEEK_STYLE_COMPACT = "compact" + const val ISLAND_NOTIF_PEEK_STYLE_MINIMAL = "minimal" const val KEY_ISLAND_NOTIF_KEEP_PROGRESS = "island_notif_keep_progress" const val KEY_ISLAND_NOTIF_QUEUE = "island_notif_queue" + const val KEY_ISLAND_NOTIF_QUEUE_STYLE = "island_notif_queue_style" + const val ISLAND_NOTIF_QUEUE_STYLE_STACK = "stack" + const val ISLAND_NOTIF_QUEUE_STYLE_OVERLAP = "overlap" + const val KEY_ISLAND_NOTIF_QUEUE_INLINE = "island_notif_queue_inline" const val KEY_ISLAND_NOTIF_SKIP_SILENT = "island_notif_skip_silent" const val KEY_ISLAND_NOTIF_FOLLOW_DND = "island_notif_follow_dnd" const val KEY_ISLAND_NOTIF_FILTER_APPS = "island_notif_filter_apps" @@ -4146,6 +4155,14 @@ class SettingsRepository( fun isIslandNotifQueueEnabled(): Boolean = getBoolean(KEY_ISLAND_NOTIF_QUEUE, true) fun setIslandNotifQueueEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_QUEUE, enabled) + fun getIslandNotifQueueStyle(): String = + if (getString(KEY_ISLAND_NOTIF_QUEUE_STYLE, null) == ISLAND_NOTIF_QUEUE_STYLE_OVERLAP) ISLAND_NOTIF_QUEUE_STYLE_OVERLAP + else ISLAND_NOTIF_QUEUE_STYLE_STACK + fun setIslandNotifQueueStyle(style: String) = putString(KEY_ISLAND_NOTIF_QUEUE_STYLE, style) + + fun isIslandNotifQueueInlineEnabled(): Boolean = getBoolean(KEY_ISLAND_NOTIF_QUEUE_INLINE, false) + fun setIslandNotifQueueInlineEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_QUEUE_INLINE, enabled) + fun isFaceUnlockBrightnessEnabled(): Boolean = getBoolean(KEY_FACE_UNLOCK_BRIGHTNESS_ENABLED, false) fun setFaceUnlockBrightnessEnabled(enabled: Boolean) = putBoolean(KEY_FACE_UNLOCK_BRIGHTNESS_ENABLED, enabled) @@ -4182,8 +4199,10 @@ class SettingsRepository( fun isIslandNotifCompactHeadsUpEnabled(): Boolean = getBoolean(KEY_ISLAND_NOTIF_COMPACT_HEADS_UP, true) fun setIslandNotifCompactHeadsUpEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_COMPACT_HEADS_UP, enabled) - fun isIslandNotifCondensedInfoEnabled(): Boolean = getBoolean(KEY_ISLAND_NOTIF_CONDENSED_INFO, false) - fun setIslandNotifCondensedInfoEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_CONDENSED_INFO, enabled) + fun getIslandNotifPeekStyle(): String = + getString(KEY_ISLAND_NOTIF_PEEK_STYLE, null) + ?: if (getBoolean(KEY_ISLAND_NOTIF_CONDENSED_INFO, false)) ISLAND_NOTIF_PEEK_STYLE_COMPACT else ISLAND_NOTIF_PEEK_STYLE_FULL + fun setIslandNotifPeekStyle(style: String) = putString(KEY_ISLAND_NOTIF_PEEK_STYLE, style) fun isIslandNotifKeepProgressEnabled(): Boolean = getBoolean(KEY_ISLAND_NOTIF_KEEP_PROGRESS, true) fun setIslandNotifKeepProgressEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_KEEP_PROGRESS, enabled) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 61443cc45..01196e9c1 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -995,14 +995,24 @@ object FeatureRegistry { ), SearchSetting( R.string.island_notif_condensed_info_title, - R.string.island_notif_condensed_info_desc, - "island_notif_condensed_info", + R.string.island_notif_peek_style_search_desc, + "island_notif_peek_style", ), SearchSetting( R.string.island_notif_queue_title, R.string.island_notif_queue_desc, "island_notif_queue", ), + SearchSetting( + R.string.island_notif_queue_style_title, + R.string.island_notif_queue_style_search_desc, + "island_notif_queue_style", + ), + SearchSetting( + R.string.island_notif_queue_inline_title, + R.string.island_notif_queue_inline_desc, + "island_notif_queue_inline", + ), SearchSetting( R.string.island_notif_tap_to_open_title, R.string.island_notif_tap_to_open_desc, diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt index 6f26dc92f..56add3ee4 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt @@ -136,6 +136,8 @@ class IslandItem( val bypassLauncherOnly: Boolean = false, val stack: List = emptyList(), + // The waiting part of `stack` in the order it will be shown; null means `stack` minus the current icon. + val queued: List? = null, val priorityGroup: String? = null, ) { var userPriority: Int? = 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 3b055b03c..1ced4ad90 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 @@ -27,6 +27,8 @@ import com.sameerasw.essentials.island.plugins.BaseIslandPlugin import com.sameerasw.essentials.island.plugins.launchPackage import com.sameerasw.essentials.island.plugins.sendPendingIntent 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.services.NotificationListener import com.sameerasw.essentials.utils.HapticUtil @@ -37,7 +39,9 @@ class NotificationsPlugin : BaseIslandPlugin() { override val settingKeys = setOf( SettingsRepository.KEY_ISLAND_CATCH_UP_ENABLED, SettingsRepository.KEY_ISLAND_NOTIF_COMPACT_HEADS_UP, - SettingsRepository.KEY_ISLAND_NOTIF_CONDENSED_INFO, + SettingsRepository.KEY_ISLAND_NOTIF_PEEK_STYLE, + SettingsRepository.KEY_ISLAND_NOTIF_QUEUE_STYLE, + SettingsRepository.KEY_ISLAND_NOTIF_QUEUE_INLINE, SettingsRepository.KEY_ISLAND_LINE_STAGE_ENABLED, SettingsRepository.KEY_ISLAND_SHOW_GLOW, SettingsRepository.KEY_ISLAND_NOTIF_QUEUE, @@ -90,6 +94,12 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun currentAlert(): ActiveNotificationAlert? = alerts.getOrNull(currentIndex.coerceIn(0, (alerts.size - 1).coerceAtLeast(0))) + private fun waitingInOrder(current: ActiveNotificationAlert): List { + val index = alerts.indexOf(current) + if (index < 0) return alerts.filter { it !== current } + return alerts.drop(index + 1) + alerts.take(index).asReversed() + } + private fun removeCurrent() { if (alerts.isEmpty()) return alerts.removeAt(currentIndex.coerceIn(0, alerts.size - 1)) @@ -198,6 +208,12 @@ class NotificationsPlugin : BaseIslandPlugin() { manualPick = false val cap = if (queueEnabled()) MAX_QUEUE else 1 while (alerts.size > cap) alerts.removeLast() + if (minimal()) { + pendingPopUp = false + holdMinimal() + render() + return + } if (busyElsewhere()) { pendingPopUp = true cancelTimers() @@ -217,6 +233,7 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun popUp() { val c = ctx ?: return + if (minimal()) return val lineAvailable = settings.isIslandLineStageEnabled() && settings.isIslandNotifCompactHeadsUpEnabled() c.request( if (lineAvailable) PluginRequest.Peek(ITEM_KEY, settings.getIslandTimeoutMs(), sticky = true) @@ -267,6 +284,18 @@ class NotificationsPlugin : BaseIslandPlugin() { clearAll() } + // With catch up on, the minimal icon stays for the catch up duration; otherwise for the regular timeout. + private fun holdMinimal() { + val c = ctx ?: return + if (!settings.isIslandCatchUpEnabled()) { + scheduleTimeout() + return + } + cancelTimers() + catchingUp = true + if (!settings.isIslandCatchUpInfinite()) c.mainHandler.postDelayed(catchUpRunnable, settings.getIslandCatchUpTimeoutMs()) + } + private fun scheduleTimeout() { val handler = ctx?.mainHandler ?: return handler.removeCallbacks(timeoutRunnable) @@ -309,20 +338,32 @@ class NotificationsPlugin : BaseIslandPlugin() { val index = alerts.indexOf(alert) val next = if (queueEnabled()) alerts.getOrNull(index + 1) ?: alerts.getOrNull(index - 1) else null val stack = alerts.map { stackIconFor(it, current = it === alert) } - val bubble = if (settings.isIslandCatchUpEnabled()) { - SideBubble( + val queued = waitingInOrder(alert).map { stackIconFor(it, current = false) } + val minimal = minimal() + val waiting = if (minimal) queued else emptyList() + val inlineQueue = minimal && waiting.isNotEmpty() && settings.isIslandNotifQueueInlineEnabled() + val bubble = when { + minimal && waiting.isNotEmpty() && !inlineQueue -> SideBubble( + priority = SideBubblePriority.NOTIFICATION, + icons = waiting, + onOpen = { select(waiting.first().key) }, + onDismiss = { clearWaiting() }, + ) + minimal -> null + settings.isIslandCatchUpEnabled() -> SideBubble( priority = SideBubblePriority.NOTIFICATION, icons = alerts.map { stackIconFor(it, current = false) }, onOpen = { select(alert.key) }, onDismiss = { clearAll() }, ) - } else { - null + else -> null } publish( itemFor(alert).let { current -> current.withStack( sideBubble = bubble, + compactVisible = minimal || bubble == null, + leadingCell = if (inlineQueue) queueCell(waiting) else null, queue = next?.let { QueueInfo( next = itemFor(it).withStack(null, alerts.map { a -> stackIconFor(a, current = a === it) }), @@ -330,6 +371,7 @@ class NotificationsPlugin : BaseIslandPlugin() { ) }, stack = stack, + queued = queued, ) }, ) @@ -337,8 +379,30 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun queueEnabled() = settings.isIslandNotifQueueEnabled() - private fun condensedLine() = settings.isIslandNotifCondensedInfoEnabled() && - settings.isIslandLineStageEnabled() && settings.isIslandNotifCompactHeadsUpEnabled() + private fun queueCell(icons: List): CompactCell = CompactCell("notif.queue") { + when (settings.getIslandNotifQueueStyle()) { + SettingsRepository.ISLAND_NOTIF_QUEUE_STYLE_OVERLAP -> IslandStackOverlap(icons, 22.dp) + else -> IslandStackBubble(icons, 26.dp) + } + } + + private fun clearWaiting() { + val current = currentAlert() ?: return + alerts.removeAll { it !== current } + currentIndex = 0 + render() + } + + private fun peekStyle(): String = + if (settings.isIslandLineStageEnabled() && settings.isIslandNotifCompactHeadsUpEnabled()) { + settings.getIslandNotifPeekStyle() + } else { + SettingsRepository.ISLAND_NOTIF_PEEK_STYLE_FULL + } + + private fun condensedLine() = peekStyle() == SettingsRepository.ISLAND_NOTIF_PEEK_STYLE_COMPACT + + private fun minimal() = peekStyle() == SettingsRepository.ISLAND_NOTIF_PEEK_STYLE_MINIMAL private fun itemFor(alert: ActiveNotificationAlert): IslandItem { if (concealed()) return concealedItemFor(alert) @@ -358,7 +422,7 @@ class NotificationsPlugin : BaseIslandPlugin() { }, ) val appIcon = alert.appIcon - if (alert.chatIcon != null && appIcon != null) { + if (!minimal() && alert.chatIcon != null && appIcon != null) { add(CompactCell("notif.app", soloOnly = true) { IslandBitmap(appIcon, 22.dp, circle = true) }) } }, @@ -415,7 +479,7 @@ class NotificationsPlugin : BaseIslandPlugin() { }, ) val appIcon = alert.appIcon - if (!hideChat && alert.chatIcon != null && appIcon != null) { + if (!minimal() && !hideChat && alert.chatIcon != null && appIcon != null) { add(CompactCell("notif.app", soloOnly = true) { IslandBitmap(appIcon, 22.dp, circle = true) }) } }, @@ -445,11 +509,18 @@ class NotificationsPlugin : BaseIslandPlugin() { } } - private fun IslandItem.withStack(queue: QueueInfo?, stack: List, sideBubble: SideBubble? = null) = IslandItem( + private fun IslandItem.withStack( + queue: QueueInfo?, + stack: List, + sideBubble: SideBubble? = null, + compactVisible: Boolean = sideBubble == null, + leadingCell: CompactCell? = null, + queued: List? = null, + ) = IslandItem( key = key, priority = priority, placement = placement, - compact = compact, + compact = if (leadingCell != null) listOf(leadingCell) + compact.take(1) else compact, line = line, expanded = expanded, accent = accent, @@ -461,7 +532,8 @@ class NotificationsPlugin : BaseIslandPlugin() { sourcePackage = sourcePackage, contentId = contentId, stack = stack, - compactVisible = sideBubble == null, + queued = queued, + compactVisible = compactVisible, sideBubble = sideBubble, ) 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 6885c88ea..9c741b762 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 @@ -65,6 +65,12 @@ import com.sameerasw.essentials.island.ui.IslandFontFamily import com.sameerasw.essentials.island.ui.IslandLayoutSpec import com.sameerasw.essentials.island.ui.IslandMotion import com.sameerasw.essentials.island.ui.IslandRoot +import com.sameerasw.essentials.island.ui.QueueStyle +import com.sameerasw.essentials.island.ui.IslandStackBubble +import com.sameerasw.essentials.island.ui.IslandStackOverlap +import com.sameerasw.essentials.island.model.SideBubble +import com.sameerasw.essentials.island.model.SideBubblePriority +import com.sameerasw.essentials.island.model.StackIcon import com.sameerasw.essentials.utils.ShellUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -470,10 +476,52 @@ class IslandCoordinator( .distinctUntilChanged() .collect { (stage, key) -> plugins.forEach { it.onFocusChanged(stage, key) } } } + newScope.launch { IslandPreviewRequests.queue.collect { showQueuePreview() } } if (settings.isIslandSuppressSystemHeadsUpEnabled()) settings.applyHeadsUpSuppression(true) applyPreviewStage() } + private val endQueuePreview = Runnable { + controller.holdFocus = settings.getIslandPreviewStage() != SettingsRepository.ISLAND_PREVIEW_STAGE_AUTO + controller.setItems(QUEUE_PREVIEW_SOURCE, emptyList()) + } + + private fun showQueuePreview() { + mainHandler.post { + if (!running) return@post + mainHandler.removeCallbacks(endQueuePreview) + val minimal = settings.isIslandLineStageEnabled() && settings.isIslandNotifCompactHeadsUpEnabled() && + settings.getIslandNotifPeekStyle() == SettingsRepository.ISLAND_NOTIF_PEEK_STYLE_MINIMAL + val layout = when { + !minimal -> QueuePreviewLayout.Peek + settings.isIslandNotifQueueInlineEnabled() -> QueuePreviewLayout.MinimalInline + else -> QueuePreviewLayout.MinimalStack + } + controller.holdFocus = layout == QueuePreviewLayout.Peek + controller.setItems( + QUEUE_PREVIEW_SOURCE, + listOf( + queuePreviewItem( + service.getString(R.string.island_preview_sample_title), + service.getString(R.string.island_preview_sample_text), + layout, + overlap = settings.getIslandNotifQueueStyle() == SettingsRepository.ISLAND_NOTIF_QUEUE_STYLE_OVERLAP, + ), + ), + ) + val state = controller.state.value + if (layout == QueuePreviewLayout.Peek) { + if (state.focusedKey != QUEUE_PREVIEW_KEY || state.stage != IslandStage.Line) { + controller.collapseImmediately() + controller.peek(QUEUE_PREVIEW_KEY, 0L, force = true) + } + } else if (state.focusedKey == QUEUE_PREVIEW_KEY) { + controller.collapseImmediately() + } + mainHandler.postDelayed(endQueuePreview, QUEUE_PREVIEW_HOLD_MS) + } + } + private fun applyPreviewRing() { showRing.value = settings.isIslandPreviewRingEnabled() } @@ -520,6 +568,39 @@ class IslandCoordinator( compactVisible = false, ) + private enum class QueuePreviewLayout { Peek, MinimalInline, MinimalStack } + + // Sample notification with two waiting icons, shaped like NotificationsPlugin's output for the given layout. + private fun queuePreviewItem(title: String, text: String, layout: QueuePreviewLayout, overlap: Boolean): IslandItem { + fun icon(key: String, current: Boolean = false) = StackIcon(key, current = current) { size -> + IslandIcon(R.drawable.rounded_notifications_unread_24, size = size * 0.7f) + } + val waiting = listOf(icon("$QUEUE_PREVIEW_KEY.1"), icon("$QUEUE_PREVIEW_KEY.2")) + val iconCell = CompactCell("preview.queue.icon") { IslandIcon(R.drawable.rounded_notifications_unread_24, size = 18.dp) } + val queueCell = CompactCell("preview.queue.stack") { + if (overlap) IslandStackOverlap(waiting, 22.dp) else IslandStackBubble(waiting, 26.dp) + } + return IslandItem( + key = QUEUE_PREVIEW_KEY, + priority = IslandPriority.NOTIFICATION, + placement = CompactPlacement.Dynamic, + compact = if (layout == QueuePreviewLayout.MinimalInline) listOf(queueCell, iconCell) else listOf(iconCell), + line = LineContent( + icon = { IslandIcon(R.drawable.rounded_notifications_unread_24, size = 24.dp) }, + start = title, + end = text, + ), + compactVisible = layout != QueuePreviewLayout.Peek, + sideBubble = if (layout == QueuePreviewLayout.MinimalStack) { + SideBubble(priority = SideBubblePriority.NOTIFICATION, onOpen = {}, icons = waiting) + } else { + null + }, + stack = listOf(icon(QUEUE_PREVIEW_KEY, current = true)) + waiting, + queued = waiting, + ) + } + private var revealing = false private var hiding = false private var pendingStop: Runnable? = null @@ -548,6 +629,8 @@ class IslandCoordinator( running = false controller.holdFocus = false controller.setItems(PREVIEW_SOURCE, emptyList()) + mainHandler.removeCallbacks(endQueuePreview) + controller.setItems(QUEUE_PREVIEW_SOURCE, emptyList()) plugins.forEach { it.stop() controller.setItems(it.id, emptyList()) @@ -608,6 +691,11 @@ class IslandCoordinator( pulseYShift = settings.getIslandPulseShadowYShift().coerceIn(0f, 1f), pulseSpread = settings.getIslandPulseShadowSpread().coerceIn(1f, 4f), pulseDurationMs = settings.getIslandPulseShadowDurationMs().coerceIn(300f, 4000f).toInt(), + queueStyle = when (settings.getIslandNotifQueueStyle()) { + SettingsRepository.ISLAND_NOTIF_QUEUE_STYLE_OVERLAP -> QueueStyle.Overlap + else -> QueueStyle.Stack + }, + queueInline = settings.isIslandNotifQueueInlineEnabled(), ) windowHost.maxWidthPx = (maxOf(lineWidth, expandedWidth * scale) * density).toInt() windowHost.updateGeometry(geo) @@ -673,12 +761,17 @@ class IslandCoordinator( const val SETTLE_MS = 300L const val PREVIEW_SOURCE = "preview" const val PREVIEW_KEY = "preview.sample" + const val QUEUE_PREVIEW_SOURCE = "preview.queue" + const val QUEUE_PREVIEW_KEY = "preview.queue.sample" + const val QUEUE_PREVIEW_HOLD_MS = 5000L val LAUNCHER_ONLY_KEYS = mapOf( "time_battery" to SettingsRepository.KEY_ISLAND_TIME_BATTERY_LAUNCHER_ONLY, "calendar" to SettingsRepository.KEY_ISLAND_CALENDAR_LAUNCHER_ONLY, "alarm" to SettingsRepository.KEY_ISLAND_ALARM_LAUNCHER_ONLY, ) val CONFIG_KEYS = setOf( + SettingsRepository.KEY_ISLAND_NOTIF_QUEUE_STYLE, + SettingsRepository.KEY_ISLAND_NOTIF_QUEUE_INLINE, SettingsRepository.KEY_ISLAND_USE_AUTO_DETECT, SettingsRepository.KEY_ISLAND_CAMERA_OFFSET_X, SettingsRepository.KEY_ISLAND_CAMERA_OFFSET_Y, diff --git a/app/src/main/java/com/sameerasw/essentials/island/service/IslandPreviewRequests.kt b/app/src/main/java/com/sameerasw/essentials/island/service/IslandPreviewRequests.kt new file mode 100644 index 000000000..f6646a961 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/service/IslandPreviewRequests.kt @@ -0,0 +1,14 @@ +package com.sameerasw.essentials.island.service + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +// Lets settings UI ask a running island to briefly show a sample notification queue. +object IslandPreviewRequests { + private val _queue = MutableSharedFlow(extraBufferCapacity = 1) + val queue: SharedFlow = _queue + + fun showQueue() { + _queue.tryEmit(Unit) + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt index a13ae7cab..0036ad85c 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt @@ -1,5 +1,6 @@ package com.sameerasw.essentials.island.ui +import kotlin.math.roundToInt import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.expandHorizontally @@ -35,7 +36,10 @@ fun CompactTemplate( spec: IslandLayoutSpec, onCellTap: (String) -> Unit, onCellLongPress: (String) -> Unit, - leftBias: () -> Int = { 0 }, + // 0..1 while a side bubble claims the space left of the camera: the sides stop mirroring and hug their own cells. + sideBubble: () -> Float = { 0f }, + // Right side minus left side once hugging; the surface shifts by half of it so the camera slot stays on the camera. + onSlack: (Int) -> Unit = {}, ) { val cells = buildMap { state.items.values.forEach { item -> item.compact.forEach { put(it.key, item.key to it) } } @@ -138,9 +142,11 @@ fun CompactTemplate( val edge = if (hasCells) spacing else 0 val needLeft = content(left) + leftInset + edge val needRight = content(right) + rightInset + edge - val bias = if (hasCells) leftBias().coerceAtLeast(0) else 0 - val rightSide = maxOf(needRight, needLeft + bias, if (bias == 0) needLeft else 0) - val leftSide = if (bias == 0) rightSide else rightSide - bias + val t = if (hasCells) sideBubble().coerceIn(0f, 1f) else 0f + val mirrored = maxOf(needLeft, needRight) + onSlack(needRight - needLeft) + val leftSide = (mirrored + (needLeft - mirrored) * t).roundToInt() + val rightSide = (mirrored + (needRight - mirrored) * t).roundToInt() val width = leftSide + rightSide + cameraSlot layout(width, height) { var x = spacing + leftInset 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 2bf592e83..eab53a41d 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 @@ -6,6 +6,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +enum class QueueStyle { Stack, Overlap } + @Immutable data class IslandLayoutSpec( val cameraDiameter: Dp = 28.dp, @@ -35,6 +37,8 @@ data class IslandLayoutSpec( val pulseYShift: Float = 0.35f, val pulseSpread: Float = 2f, val pulseDurationMs: Int = 1450, + val queueStyle: QueueStyle = QueueStyle.Stack, + val queueInline: Boolean = false, ) { val compactHeight: Dp get() = cameraDiameter + verticalGap * 2 val cameraSlotWidth: Dp get() = cameraDiameter * cameraPresence + cameraGap * 2 diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandMotion.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandMotion.kt index 7929f67f6..5b50b0ad9 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandMotion.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandMotion.kt @@ -37,6 +37,13 @@ object IslandMotion { const val CONTENT_SCALE = 0.06f fun contentSpring(): FiniteAnimationSpec = spring(0.88f, 360f) + fun expressiveFastSpatial(): FiniteAnimationSpec = spring(0.6f, 800f) + fun expressiveDefaultSpatial(): FiniteAnimationSpec = spring(0.8f, 380f) + fun expressiveSlowSpatial(): FiniteAnimationSpec = spring(0.8f, 200f) + fun expressiveFastEffects(): FiniteAnimationSpec = spring(1f, 3800f) + fun expressiveDefaultEffects(): FiniteAnimationSpec = spring(1f, 1600f) + fun expressiveSlowEffects(): FiniteAnimationSpec = spring(1f, 800f) + const val COLLAPSE_THRESHOLD = 0.3f const val DISMISS_THRESHOLD = 0.3f } 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 a46da1bfa..309dd55c0 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 @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.first import androidx.compose.runtime.snapshotFlow import kotlin.math.hypot import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import kotlinx.coroutines.CancellationException import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableFloatStateOf @@ -121,6 +122,7 @@ interface IslandActions { } private val LocalLineInset = compositionLocalOf { 0.dp to 0.dp } +private val LocalLineLeading = compositionLocalOf { null } private data class ContentKey(val stage: IslandStage, val itemKey: String?) @@ -145,6 +147,7 @@ fun IslandRoot( val stage = state.stage val key = ContentKey(stage, state.focusedKey) val currentState by rememberUpdatedState(state) + val inspection = LocalInspectionMode.current val bondCandidate = targetSpec.bondEdge && stage == IslandStage.Compact && state.sideBubble == null var bonded by remember { mutableStateOf(false) } @@ -271,6 +274,7 @@ fun IslandRoot( } LaunchedEffect(key) { + if (inspection) return@LaunchedEffect if (stage != IslandStage.Hidden) { visible = true squashStarted[0] = false @@ -394,27 +398,43 @@ fun IslandRoot( val bubbleOnlyLayout = !hasCompactCells && spec.cameraPresence <= 0f val catchUpAnim = remember { Animatable(0f) } LaunchedEffect(catchUpShown) { + if (inspection) { + catchUpAnim.snapTo(if (catchUpShown) 1f else 0f) + return@LaunchedEffect + } if (catchUpShown) { withTimeoutOrNull(IslandMotion.COLLAPSE_MS * 2L) { snapshotFlow { surfaceSize.width to compactSize.width } .first { (live, target) -> target > 0 && abs(live - target) <= 6 } } - catchUpAnim.animateTo(1f, IslandMotion.compactFloat()) + catchUpAnim.animateTo(1f, IslandMotion.expressiveDefaultSpatial()) } else { - catchUpAnim.animateTo(0f, IslandMotion.compactFloat()) + catchUpAnim.animateTo(0f, IslandMotion.expressiveFastEffects()) } } val bubbleSizePx = with(density) { spec.compactHeight.roundToPx() } + val sideBubbleWidth = lastCatchUp + ?.takeIf { spec.queueStyle == QueueStyle.Overlap && it.content == null && it.icons.isNotEmpty() } + ?.let { stackOverlapWidth(it.icons.size, spec.compactHeight) } + ?: spec.compactHeight + val sideWidthPx = with(density) { sideBubbleWidth.roundToPx() } + val sideWidthAnim = remember { Animatable(sideWidthPx.toFloat()) } + LaunchedEffect(sideWidthPx) { + if (inspection) sideWidthAnim.snapTo(sideWidthPx.toFloat()) else sideWidthAnim.animateTo(sideWidthPx.toFloat(), IslandMotion.expressiveDefaultSpatial()) + } + fun sideWidthLive(): Int = sideWidthAnim.value.roundToInt() val bubbleGapPx = with(density) { spec.cameraGap.roundToPx() } - fun catchShiftPx(): Float = - if (spec.growDirection == 0 && hasCompactCells) catchUpAnim.value.coerceAtLeast(0f) * (bubbleSizePx + bubbleGapPx) / 2f else 0f + val compactSlack = remember { mutableIntStateOf(0) } + fun sideBubbleProgress(): Float = + if (spec.growDirection == 0 && hasCompactCells) catchUpAnim.value.coerceIn(0f, 1f) else 0f + fun catchShiftPx(): Float = sideBubbleProgress() * compactSlack.intValue / 2f val lineShift = remember { Animatable(0f) } val liveSurfaceHeight = remember { mutableIntStateOf(0) } fun surfaceShiftFor(width: Int): Int = spec.growDirection * (width - cameraSlotPx) / 2 fun surfaceLeft(width: Int): Int = windowWidth / 2 - width / 2 + surfaceShiftFor(width) + lineShift.value.roundToInt() + catchShiftPx().roundToInt() fun bubbleX(width: Int): Int = - if (spec.growDirection > 0) surfaceLeft(width) + width + bubbleGapPx else surfaceLeft(width) - bubbleGapPx - bubbleSizePx + if (spec.growDirection > 0) surfaceLeft(width) + width + bubbleGapPx else surfaceLeft(width) - bubbleGapPx - sideWidthLive() val focusedItem = state.focused val foreignStack = if (focusedItem != null && focusedItem.stack.isEmpty()) { @@ -423,25 +443,59 @@ fun IslandRoot( emptyList() } val fullStack = focusedItem?.stack?.takeIf { it.isNotEmpty() } ?: foreignStack.map { StackIcon(it.key, current = false, onSelect = it.onSelect, content = it.content) } - val queuedIcons = if (foreignStack.isNotEmpty()) fullStack else fullStack.filterNot { it.current } - val queueShown = (stage == IslandStage.Line || stage == IslandStage.Expanded) && queuedIcons.isNotEmpty() + val queuedIcons = when { + foreignStack.isNotEmpty() -> fullStack + else -> focusedItem?.queued ?: fullStack.filterNot { it.current } + } + val inlineLineQueue = spec.queueInline && stage == IslandStage.Line && queuedIcons.isNotEmpty() + val queueShown = (stage == IslandStage.Expanded || (stage == IslandStage.Line && !spec.queueInline)) && queuedIcons.isNotEmpty() var lastFullStack by remember { mutableStateOf(emptyList()) } if (queuedIcons.isNotEmpty()) lastFullStack = fullStack var pillSize by remember { mutableStateOf(IntSize.Zero) } + var lastQueuedIcons by remember { mutableStateOf(emptyList()) } + if (queuedIcons.isNotEmpty()) lastQueuedIcons = queuedIcons + val queueWidth = when (spec.queueStyle) { + QueueStyle.Stack -> spec.compactHeight + QueueStyle.Overlap -> stackOverlapWidth(lastQueuedIcons.size, spec.compactHeight) + } + val queueWidthPx = with(density) { queueWidth.roundToPx() } + val queueWidthAnim = remember { Animatable(queueWidthPx.toFloat()) } + LaunchedEffect(queueWidthPx) { + if (inspection) queueWidthAnim.snapTo(queueWidthPx.toFloat()) else queueWidthAnim.animateTo(queueWidthPx.toFloat(), IslandMotion.expressiveDefaultSpatial()) + } + fun queueWidthLive(): Int = queueWidthAnim.value.roundToInt() + fun queueX(width: Int): Int = + if (spec.growDirection > 0) surfaceLeft(width) + width + bubbleGapPx else surfaceLeft(width) - bubbleGapPx - queueWidthLive() + val inlineQueueIcons = queuedIcons.ifEmpty { lastQueuedIcons } + val inlineQueue = LineLeading( + width = when (spec.queueStyle) { + QueueStyle.Stack -> spec.cellSize + QueueStyle.Overlap -> stackOverlapWidth(inlineQueueIcons.size, spec.cellSize) + }, + ) { + val tapToAdvance = Modifier.pointerInput(Unit) { + detectTapGestures { + IslandHaptics.tap(context) + if (currentState.focused?.queue != null) actions.onAdvance() else lastQueuedIcons.firstOrNull()?.onSelect?.invoke() + } + } + when (spec.queueStyle) { + QueueStyle.Stack -> IslandStackBubble(inlineQueueIcons, spec.cellSize, modifier = tapToAdvance) + QueueStyle.Overlap -> IslandStackOverlap(inlineQueueIcons, spec.cellSize, modifier = tapToAdvance) + } + } val lineInsetActive = queueShown && stage == IslandStage.Line - val lineInset = if (lineInsetActive) spec.compactHeight + spec.cameraGap else 0.dp + val lineInset = if (lineInsetActive) queueWidth + spec.cameraGap else 0.dp val lineInsets = if (spec.growDirection > 0) 0.dp to lineInset else lineInset to 0.dp val condensedLineActive = spec.growDirection == 0 && stage == IslandStage.Line && focusedItem?.line?.condensed == true - LaunchedEffect(lineInsetActive, spec.growDirection, condensedLineActive) { + LaunchedEffect(lineInsetActive, spec.growDirection, condensedLineActive, queueWidthPx) { val shift = if (lineInsetActive && spec.growDirection == 0 && !condensedLineActive) { - (bubbleSizePx + bubbleGapPx) / 2f + (queueWidthPx + bubbleGapPx) / 2f } else { 0f } - lineShift.animateTo(shift, IslandMotion.float()) + if (inspection) lineShift.snapTo(shift) else lineShift.animateTo(shift, IslandMotion.expressiveDefaultSpatial()) } - var lastQueuedIcons by remember { mutableStateOf(emptyList()) } - if (queuedIcons.isNotEmpty()) lastQueuedIcons = queuedIcons val queueBelow = remember { Animatable(0f) } val pillReveal = remember { Animatable(0f) } @@ -455,6 +509,10 @@ fun IslandRoot( } } LaunchedEffect(stage) { + if (inspection) { + queueBelow.snapTo(if (stage == IslandStage.Expanded) 1f else 0f) + return@LaunchedEffect + } when (stage) { IslandStage.Expanded -> queueBelow.animateTo(1f, IslandMotion.float()) IslandStage.Line -> queueBelow.animateTo(0f, IslandMotion.float()) @@ -464,28 +522,29 @@ fun IslandRoot( LaunchedEffect( target, windowWidth, stage, queueShown, pillSize, lineInsetActive, catchUpShown, hasCompactCells, - bubbleOnlyLayout, condensedLineActive, + bubbleOnlyLayout, condensedLineActive, queueWidthPx, sideWidthPx, compactSlack.intValue, ) { if (target == IntSize.Zero || windowWidth == 0) return@LaunchedEffect val g = if (stage == IslandStage.Expanded) outsetPx.roundToInt() else 0 - val shift = if ((lineInsetActive || (catchUpShown && hasCompactCells)) && spec.growDirection == 0 && !condensedLineActive) { - (bubbleSizePx + bubbleGapPx) / 2 - } else { - 0 + val shift = when { + spec.growDirection != 0 || condensedLineActive -> 0 + lineInsetActive -> (queueWidthPx + bubbleGapPx) / 2 + catchUpShown && hasCompactCells -> compactSlack.intValue / 2 + else -> 0 } val left = windowWidth / 2 - target.width / 2 + surfaceShiftFor(target.width) + shift val top = (surfaceTopPx - g).coerceAtLeast(0) var bounds = IntRect(left, top, left + target.width, surfaceTopPx - g + target.height) if (queueShown && stage == IslandStage.Line) { - val bx = if (spec.growDirection > 0) left + target.width + bubbleGapPx else left - bubbleGapPx - bubbleSizePx - bounds = IntRect(minOf(bounds.left, bx), bounds.top, maxOf(bounds.right, bx + bubbleSizePx), maxOf(bounds.bottom, surfaceTopPx + bubbleSizePx)) + val bx = if (spec.growDirection > 0) left + target.width + bubbleGapPx else left - bubbleGapPx - queueWidthPx + bounds = IntRect(minOf(bounds.left, bx), bounds.top, maxOf(bounds.right, bx + queueWidthPx), maxOf(bounds.bottom, surfaceTopPx + bubbleSizePx)) } if (catchUpShown && bubbleOnlyLayout) { - val bx = windowWidth / 2 - bubbleSizePx / 2 - bounds = IntRect(bx, surfaceTopPx, bx + bubbleSizePx, surfaceTopPx + bubbleSizePx) + val bx = windowWidth / 2 - sideWidthPx / 2 + bounds = IntRect(bx, surfaceTopPx, bx + sideWidthPx, surfaceTopPx + bubbleSizePx) } else if (catchUpShown) { - val bx = if (spec.growDirection > 0) left + target.width + bubbleGapPx else left - bubbleGapPx - bubbleSizePx - bounds = IntRect(minOf(bounds.left, bx), bounds.top, maxOf(bounds.right, bx + bubbleSizePx), maxOf(bounds.bottom, surfaceTopPx + bubbleSizePx)) + val bx = if (spec.growDirection > 0) left + target.width + bubbleGapPx else left - bubbleGapPx - sideWidthPx + bounds = IntRect(minOf(bounds.left, bx), bounds.top, maxOf(bounds.right, bx + sideWidthPx), maxOf(bounds.bottom, surfaceTopPx + bubbleSizePx)) } if (queueShown && stage == IslandStage.Expanded && pillSize != IntSize.Zero) { val pillLeft = left + (target.width - pillSize.width) / 2 @@ -522,7 +581,7 @@ fun IslandRoot( val p = measurable.measure(Constraints()) layout(constraints.maxWidth, constraints.maxHeight) { val w = surfaceSize.width - val sideX = bubbleX(w) + bubbleSizePx / 2f + val sideX = queueX(w) + queueWidthLive() / 2f val sideY = surfaceTopPx + bubbleSizePx / 2f val belowX = surfaceLeft(w) + w / 2f val belowY = surfaceTopPx + liveSurfaceHeight.intValue + pillGapPx + p.height / 2f @@ -538,25 +597,35 @@ fun IslandRoot( } } } - IslandStackBubble( - icons = lastQueuedIcons, - size = spec.compactHeight, - outlineColor = outlineFor(1f), - outlineThickness = spec.outlineThickness, - modifier = Modifier - .queueSlot(1f, 0.6f) { k -> (1f - k * 2f).coerceIn(0f, 1f) } - .pointerInput(Unit) { - detectTapGestures { - if (currentState.stage != IslandStage.Line) return@detectTapGestures - IslandHaptics.tap(context) - if (currentState.focused?.queue != null) { - actions.onAdvance() - } else { - lastQueuedIcons.firstOrNull()?.onSelect?.invoke() - } + val queueModifier = Modifier + .queueSlot(1f, 0.6f) { k -> (1f - k * 2f).coerceIn(0f, 1f) } + .pointerInput(Unit) { + detectTapGestures { + if (currentState.stage != IslandStage.Line) return@detectTapGestures + IslandHaptics.tap(context) + if (currentState.focused?.queue != null) { + actions.onAdvance() + } else { + lastQueuedIcons.firstOrNull()?.onSelect?.invoke() } - }, - ) + } + } + when (spec.queueStyle) { + QueueStyle.Stack -> IslandStackBubble( + icons = lastQueuedIcons, + size = spec.compactHeight, + outlineColor = outlineFor(1f), + outlineThickness = spec.outlineThickness, + modifier = queueModifier, + ) + QueueStyle.Overlap -> IslandStackOverlap( + icons = lastQueuedIcons, + size = spec.compactHeight, + outlineColor = outlineFor(1f), + outlineThickness = spec.outlineThickness, + modifier = queueModifier, + ) + } IslandStackPill( icons = lastFullStack, selectedKey = lastFullStack.firstOrNull { it.current }?.key, @@ -581,16 +650,17 @@ fun IslandRoot( ownerKey = lastBubbleKey, bubbleFor = { knownBubbles[it] }, size = spec.compactHeight, + style = spec.queueStyle, outlineColor = outlineFor(1f), outlineThickness = spec.outlineThickness, modifier = Modifier .offset { if (bubbleOnlyLayout) { - IntOffset((windowWidth / 2 - bubbleSizePx / 2 + dragX.value).roundToInt(), surfaceTopPx) + IntOffset((windowWidth / 2 - sideWidthLive() / 2 + dragX.value).roundToInt(), surfaceTopPx) } else { val w = surfaceSize.width val endX = bubbleX(w) - val startX = if (spec.growDirection > 0) surfaceLeft(w) + w - bubbleSizePx else surfaceLeft(w) + val startX = if (spec.growDirection > 0) surfaceLeft(w) + w - sideWidthLive() else surfaceLeft(w) val v = catchUpAnim.value IntOffset((startX + (endX - startX) * v + dragX.value).roundToInt(), surfaceTopPx) } @@ -1050,12 +1120,14 @@ fun IslandRoot( LocalIslandBackdropSlot provides backdropSlot.takeIf { current }, LocalIslandCollapse provides { if (current) collapse.value else 0f }, LocalLineInset provides if (current) lineInsets else (0.dp to 0.dp), + LocalLineLeading provides inlineQueue.takeIf { current && inlineLineQueue }, ) { StageContent( layerKey.stage, layerItem, state, spec, actions, interactive = current, onCellTap = ::handleTap, onCellLongPress = ::handleLongPress, - leftBias = { (catchShiftPx() * 2f).roundToInt() }, + sideBubble = ::sideBubbleProgress, + onSlack = { if (current) compactSlack.intValue = it }, ) } } @@ -1083,7 +1155,7 @@ fun IslandRoot( }, contentAlignment = layerAlign, ) { - CompactTemplate(state = state, spec = spec, onCellTap = {}, onCellLongPress = {}, leftBias = { (catchShiftPx() * 2f).roundToInt() }) + CompactTemplate(state = state, spec = spec, onCellTap = {}, onCellLongPress = {}, sideBubble = ::sideBubbleProgress) } } } @@ -1177,7 +1249,8 @@ private fun StageContent( interactive: Boolean, onCellTap: (String) -> Unit = {}, onCellLongPress: (String) -> Unit = {}, - leftBias: () -> Int = { 0 }, + sideBubble: () -> Float = { 0f }, + onSlack: (Int) -> Unit = {}, ) { val a = if (interactive) actions else NoActions when (stage) { @@ -1193,13 +1266,14 @@ private fun StageContent( spec = spec, onCellTap = { if (interactive) onCellTap(it) }, onCellLongPress = { if (interactive) onCellLongPress(it) }, - leftBias = leftBias, + sideBubble = sideBubble, + onSlack = onSlack, ) } } IslandStage.Line -> item?.line?.let { val inset = LocalLineInset.current - LineTemplate(it, spec, inset.first, inset.second) + LineTemplate(it, spec, inset.first, inset.second, leading = LocalLineLeading.current) } ?: Spacer(Modifier.size(spec.lineWidth, spec.compactHeight)) IslandStage.Expanded -> item?.let { diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandStack.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandStack.kt index 42646ee75..6919ba069 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandStack.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandStack.kt @@ -1,10 +1,16 @@ package com.sameerasw.essentials.island.ui import com.sameerasw.essentials.island.model.SideBubble +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.runtime.SideEffect import androidx.compose.animation.togetherWith import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeIn -import androidx.compose.animation.core.tween import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background @@ -13,6 +19,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.animation.core.animateFloatAsState @@ -43,6 +50,38 @@ import com.sameerasw.essentials.island.model.StackIcon private const val BUBBLE_MAX_MINIS = 3 private const val PILL_MAX_ICONS = 5 +private class QueueEntry(val icon: StackIcon, val visibility: MutableTransitionState, val leaving: Boolean) + +@Composable +private fun rememberQueueEntries(icons: List): List { + val transitions = remember { HashMap>() } + val previous = remember { arrayListOf() } + val settled = remember { booleanArrayOf(false) } + val currentKeys = icons.mapTo(HashSet()) { it.key } + val leaving = previous.mapNotNull { icon -> + if (icon.key in currentKeys) return@mapNotNull null + val state = transitions[icon.key] ?: return@mapNotNull null + if (state.isIdle && !state.currentState) { + transitions.remove(icon.key) + return@mapNotNull null + } + state.targetState = false + QueueEntry(icon, state, leaving = true) + } + val current = icons.map { icon -> + val state = transitions.getOrPut(icon.key) { MutableTransitionState(!settled[0]) } + state.targetState = true + QueueEntry(icon, state, leaving = false) + } + previous.clear() + previous.addAll(icons + leaving.map { it.icon }) + SideEffect { settled[0] = true } + return leaving + current +} + +private val QueueEnter = scaleIn(IslandMotion.expressiveFastSpatial(), initialScale = 0.4f) + fadeIn(IslandMotion.expressiveFastEffects()) +private val QueueExit = scaleOut(IslandMotion.expressiveFastEffects(), targetScale = 0.6f) + fadeOut(IslandMotion.expressiveFastEffects()) + @Composable fun IslandStackBubble( icons: List, @@ -51,7 +90,7 @@ fun IslandStackBubble( outlineColor: Color? = null, outlineThickness: Dp = 1.dp, ) { - val shown = icons.takeLast(BUBBLE_MAX_MINIS) + val shown = queueVisible(icons, BUBBLE_MAX_MINIS) val count = shown.size val iconSize = when (count) { 1 -> size * 0.62f @@ -64,6 +103,9 @@ fun IslandStackBubble( 2 -> listOf(-r to 0.dp, r to 0.dp)[i] else -> listOf(0.dp to -r, -r to r * 0.7f, r to r * 0.7f)[i] } + + val lastSlot = remember { HashMap>() } + Box( modifier = modifier .size(size) @@ -72,25 +114,105 @@ fun IslandStackBubble( .islandOutline(outlineColor, outlineThickness, CircleShape), contentAlignment = Alignment.Center, ) { - shown.forEachIndexed { i, icon -> - key(icon.key) { - val (tx, ty) = slot(i) - val x by animateDpAsState(tx, IslandMotion.compactFloat(), label = "bubbleX") - val y by animateDpAsState(ty, IslandMotion.compactFloat(), label = "bubbleY") - val s by animateDpAsState(iconSize, IslandMotion.compactFloat(), label = "bubbleSize") - val enter = remember { Animatable(0f) } - LaunchedEffect(Unit) { enter.animateTo(1f, IslandMotion.compactFloat()) } - Box( - Modifier - .offset(x, y) - .graphicsLayer { - scaleX = enter.value - scaleY = enter.value - alpha = enter.value.coerceIn(0f, 1f) - }, - contentAlignment = Alignment.Center, + rememberQueueEntries(shown).forEach { entry -> + key(entry.icon.key) { + val index = shown.indexOfFirst { it.key == entry.icon.key } + val target = if (index >= 0) slot(index).also { lastSlot[entry.icon.key] = it } else lastSlot[entry.icon.key] ?: (0.dp to 0.dp) + val x by animateDpAsState(target.first, IslandMotion.expressiveDefaultSpatial(), label = "bubbleX") + val y by animateDpAsState(target.second, IslandMotion.expressiveDefaultSpatial(), label = "bubbleY") + val s by animateDpAsState(iconSize, IslandMotion.expressiveDefaultSpatial(), label = "bubbleSize") + AnimatedVisibility( + visibleState = entry.visibility, + enter = QueueEnter, + exit = QueueExit, + modifier = Modifier.offset(x, y), + ) { + Box(Modifier.size(s), contentAlignment = Alignment.Center) { entry.icon.content(s) } + } + } + } + } +} + +private const val OVERLAP_MAX = 3 + +private fun queueVisible(icons: List, max: Int): List { + if (icons.size <= max) return icons + val drawn = icons.take(max - 1) + return drawn + queueOverflowIcon(icons.size - drawn.size) +} + +private fun queueOverflowIcon(count: Int) = StackIcon("queue_overflow") { size -> QueueOverflowCounter(count, size) } + +@Composable +private fun QueueOverflowCounter(count: Int, size: Dp) { + Box( + Modifier + .size(size) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + AnimatedContent( + targetState = count, + transitionSpec = { + val up = targetState > initialState + (slideInVertically(IslandMotion.expressiveFastSpatial()) { if (up) it else -it } + fadeIn(IslandMotion.expressiveFastEffects())) + .togetherWith(slideOutVertically(IslandMotion.expressiveFastEffects()) { if (up) -it else it } + fadeOut(IslandMotion.expressiveFastEffects())) + }, + label = "queueOverflow", + ) { shownCount -> + Text( + text = "+$shownCount", + color = Color.White, + maxLines = 1, + softWrap = false, + fontWeight = FontWeight.Bold, + fontSize = with(LocalDensity.current) { (size * if (shownCount > 9) 0.4f else 0.5f).toSp() }, + ) + } + } +} + +private const val OVERLAP_STEP = 0.55f + +fun stackOverlapWidth(count: Int, size: Dp): Dp = size + size * OVERLAP_STEP * (count.coerceIn(1, OVERLAP_MAX) - 1) + +@Composable +fun IslandStackOverlap( + icons: List, + size: Dp, + modifier: Modifier = Modifier, + outlineColor: Color? = null, + outlineThickness: Dp = 1.dp, +) { + val shown = queueVisible(icons, OVERLAP_MAX) + val width by animateDpAsState(stackOverlapWidth(shown.size, size), IslandMotion.expressiveDefaultSpatial(), label = "overlapWidth") + val lastX = remember { HashMap() } + Box(modifier.size(width, size)) { + val order = shown.asReversed() + rememberQueueEntries(order).forEach { entry -> + key(entry.icon.key) { + val index = order.indexOfFirst { it.key == entry.icon.key } + val target = if (index >= 0) (size * OVERLAP_STEP * index).also { lastX[entry.icon.key] = it } else lastX[entry.icon.key] ?: 0.dp + val x by animateDpAsState(target, IslandMotion.expressiveDefaultSpatial(), label = "overlapX") + AnimatedVisibility( + visibleState = entry.visibility, + enter = QueueEnter, + exit = QueueExit, + modifier = Modifier.offset(x = x), ) { - Box(Modifier.size(s), contentAlignment = Alignment.Center) { icon.content(s) } + Box( + Modifier + .size(size) + .clip(CircleShape) + .background(Color.Black) + .islandOutline(outlineColor, outlineThickness, CircleShape), + contentAlignment = Alignment.Center, + ) { + val iconSize = size * 0.72f + Box(Modifier.size(iconSize), contentAlignment = Alignment.Center) { entry.icon.content(iconSize) } + } } } } @@ -193,13 +315,17 @@ fun IslandSideBubble( bubbleFor: (String) -> SideBubble?, size: Dp, modifier: Modifier = Modifier, + style: QueueStyle = QueueStyle.Stack, outlineColor: Color? = null, outlineThickness: Dp = 1.dp, ) { - Box(modifier.size(size)) { + Box(modifier.height(size)) { AnimatedContent( targetState = ownerKey, - transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) }, + transitionSpec = { + (fadeIn(IslandMotion.expressiveDefaultEffects()) + scaleIn(IslandMotion.expressiveFastSpatial(), initialScale = 0.7f)) + .togetherWith(fadeOut(IslandMotion.expressiveFastEffects()) + scaleOut(IslandMotion.expressiveFastEffects(), targetScale = 0.8f)) + }, label = "sideBubble", ) { key -> val bubble = key?.let(bubbleFor) @@ -214,6 +340,8 @@ fun IslandSideBubble( .islandOutline(outlineColor, outlineThickness, CircleShape), contentAlignment = Alignment.Center, ) { content(size * 0.72f) } + } else if (style == QueueStyle.Overlap) { + IslandStackOverlap(bubble.icons, size, outlineColor = outlineColor, outlineThickness = outlineThickness) } else { IslandStackBubble(bubble.icons, size, outlineColor = outlineColor, outlineThickness = outlineThickness) } diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt index e0aca6f62..000909530 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt @@ -1,6 +1,17 @@ package com.sameerasw.essentials.island.ui import com.sameerasw.essentials.island.ui.components.MarqueeText +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -30,12 +41,21 @@ import androidx.compose.ui.unit.sp import com.sameerasw.essentials.R import com.sameerasw.essentials.island.model.LineContent +class LineLeading(val width: Dp, val content: @Composable () -> Unit) + @Composable -fun LineTemplate(line: LineContent, spec: IslandLayoutSpec, startInset: Dp = 0.dp, endInset: Dp = 0.dp) { +fun LineTemplate( + line: LineContent, + spec: IslandLayoutSpec, + startInset: Dp = 0.dp, + endInset: Dp = 0.dp, + leading: LineLeading? = null, +) { if (line.condensed) { - CondensedLine(line, spec, startInset, endInset) + CondensedLine(line, spec, startInset, endInset, leading) return } + val leadingSlot: @Composable () -> Unit = { LeadingSlot(leading, spec) } val textEndInset = if (line.endSlot == null && spec.growDirection >= 0) spec.compactHeight * OPTICAL_INSET_RATIO else 0.dp Row( modifier = Modifier @@ -56,6 +76,7 @@ fun LineTemplate(line: LineContent, spec: IslandLayoutSpec, startInset: Dp = 0.d verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spec.cellSpacing), ) { + leadingSlot() if (line.showLeftIcon) icon() LineText(line.start, TextAlign.Start, FontWeight.SemiBold, Modifier.weight(1f)) } @@ -75,6 +96,7 @@ fun LineTemplate(line: LineContent, spec: IslandLayoutSpec, startInset: Dp = 0.d verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spec.cellSpacing), ) { + leadingSlot() if (line.showLeftIcon) icon() LineText(line.start, TextAlign.Start, FontWeight.SemiBold, Modifier.weight(1f)) LineText(line.end, TextAlign.End, FontWeight.Normal, Modifier.weight(1f)) @@ -91,14 +113,14 @@ internal fun condensedTitleWidth( cameraSlotWidth: Dp, cellSize: Dp, edgePad: Dp, - endInset: Dp, + queueInset: Dp, ): Dp { - val roomBesideCamera = ((lineWidth - edgePad * 2 - cameraSlotWidth - endInset) / 2).coerceAtLeast(cellSize) + val roomBesideCamera = ((lineWidth - edgePad * 2 - cameraSlotWidth) / 2 - queueInset).coerceAtLeast(cellSize) return natural.coerceIn(cellSize, roomBesideCamera) } @Composable -private fun CondensedLine(line: LineContent, spec: IslandLayoutSpec, startInset: Dp, endInset: Dp) { +private fun CondensedLine(line: LineContent, spec: IslandLayoutSpec, startInset: Dp, endInset: Dp, leading: LineLeading?) { val style = IslandTextStyles.line.copy(fontWeight = FontWeight.SemiBold) val measurer = rememberTextMeasurer() val naturalPx = remember(line.start, style, measurer) { @@ -111,6 +133,8 @@ private fun CondensedLine(line: LineContent, spec: IslandLayoutSpec, startInset: val title: @Composable (Dp) -> Unit = { width -> MarqueeText(text = line.start, style = style, textAlign = TextAlign.Start, modifier = Modifier.width(width)) } + val leadingSlot: @Composable () -> Unit = { LeadingSlot(leading, spec) } + val leadingWidth = leading?.let { it.width + spec.cellSpacing } ?: 0.dp if (spec.growDirection == 0) { val edgePad = spec.cameraGap + spec.compactHeight * OPTICAL_INSET_RATIO @@ -120,20 +144,25 @@ private fun CondensedLine(line: LineContent, spec: IslandLayoutSpec, startInset: cameraSlotWidth = spec.cameraSlotWidth, cellSize = spec.cellSize, edgePad = edgePad, - endInset = endInset, + queueInset = maxOf(startInset, endInset), ) // Both halves get the same width so the camera slot stays dead centre and the pill reads balanced; the slack // collects beside the camera while icon and title hug the outer ends, the rule CompactTemplate already uses. // condensedTitleWidth floors at cellSize, so the icon half is never squeezed. - val side = titleWidth + edgePad + // The leading queue sits with the icon; if that side outgrows the title, both sides take its width. + val side = maxOf(titleWidth, spec.cellSize + leadingWidth) + edgePad Row( modifier = Modifier.height(spec.compactHeight), verticalAlignment = Alignment.CenterVertically, ) { - Box( + Row( modifier = Modifier.width(side).padding(start = edgePad), - contentAlignment = Alignment.CenterStart, - ) { icon() } + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spec.cellSpacing), + ) { + leadingSlot() + icon() + } Spacer(Modifier.width(spec.cameraSlotWidth)) Box( modifier = Modifier.width(side).padding(end = edgePad), @@ -142,7 +171,7 @@ private fun CondensedLine(line: LineContent, spec: IslandLayoutSpec, startInset: } } else { val opticalInset = if (spec.growDirection > 0) spec.compactHeight * OPTICAL_INSET_RATIO else 0.dp - val fixed = spec.cameraGap + opticalInset + spec.cameraSlotWidth + spec.cellSize + spec.cellSpacing * 2 + val fixed = spec.cameraGap + opticalInset + spec.cameraSlotWidth + spec.cellSize + spec.cellSpacing * 2 + leadingWidth val maxText = (spec.lineWidth - startInset - endInset - fixed).coerceAtLeast(0.dp) Row( modifier = Modifier @@ -152,6 +181,7 @@ private fun CondensedLine(line: LineContent, spec: IslandLayoutSpec, startInset: horizontalArrangement = Arrangement.spacedBy(spec.cellSpacing), ) { if (spec.growDirection > 0) Spacer(Modifier.width(spec.cameraSlotWidth - spec.cameraGap)) + leadingSlot() icon() title(natural.coerceAtMost(maxText)) if (spec.growDirection < 0) Spacer(Modifier.width(spec.cameraSlotWidth - spec.cameraGap)) @@ -159,6 +189,24 @@ private fun CondensedLine(line: LineContent, spec: IslandLayoutSpec, startInset: } } +@Composable +private fun LeadingSlot(leading: LineLeading?, spec: IslandLayoutSpec) { + var last by remember { mutableStateOf(leading) } + if (leading != null) last = leading + val width by animateDpAsState(last?.width ?: 0.dp, IslandMotion.expressiveDefaultSpatial(), label = "lineLeadingWidth") + AnimatedVisibility( + visible = leading != null, + enter = expandHorizontally(IslandMotion.expressiveDefaultSpatial()) + + scaleIn(IslandMotion.expressiveFastSpatial(), initialScale = 0.6f) + + fadeIn(IslandMotion.expressiveDefaultEffects()), + exit = shrinkHorizontally(IslandMotion.expressiveFastEffects()) + + scaleOut(IslandMotion.expressiveFastEffects(), targetScale = 0.6f) + + fadeOut(IslandMotion.expressiveFastEffects()), + ) { + Box(Modifier.width(width).height(spec.cellSize), contentAlignment = Alignment.Center) { last?.content?.invoke() } + } +} + @Composable private fun LineText(text: String, align: TextAlign, weight: FontWeight, modifier: Modifier) { MarqueeText(text = text, style = IslandTextStyles.line.copy(fontWeight = weight), textAlign = align, modifier = modifier) 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 18943a640..07f2f6d3a 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 @@ -56,6 +56,7 @@ import com.sameerasw.essentials.ui.features.display.actions.HorizontalSlideModeS import com.sameerasw.essentials.ui.features.display.actions.horizontalSlideDescription import com.sameerasw.essentials.ui.features.display.sheets.IslandAlarmOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandBorderOutlineOptionsBottomSheet +import com.sameerasw.essentials.ui.features.display.sheets.IslandNotificationQueueOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandBriefOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandCallOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandDevicesBatteryBottomSheet @@ -863,6 +864,12 @@ fun IslandNotificationSettingsUI( val context = LocalContext.current val view = LocalView.current var showFilterAppsSheet by remember { mutableStateOf(false) } + var showQueueSheet by remember { + mutableStateOf( + viewModel.isIslandNotifQueue.value && + highlightSetting in setOf("island_notif_queue_style", "island_notif_queue_inline"), + ) + } var concealed by remember { mutableStateOf( SettingsRepository(context).getBoolean(SettingsRepository.KEY_ISLAND_NOTIF_CONCEAL_LOCKED, false) @@ -990,15 +997,16 @@ fun IslandNotificationSettingsUI( enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { - val condensed = viewModel.isIslandNotifCondensedInfo.value - val compactLabel = stringResource(R.string.island_notif_peek_style_compact) + val peekStyle = viewModel.islandNotifPeekStyle.value val fullLabel = stringResource(R.string.island_notif_peek_style_full) + val compactLabel = stringResource(R.string.island_notif_peek_style_compact) + val minimalLabel = stringResource(R.string.island_notif_peek_style_minimal) Column( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall) .padding(top = 12.dp) - .highlight(highlightSetting == "island_notif_condensed_info"), + .highlight(highlightSetting == "island_notif_peek_style"), ) { Text( text = stringResource(R.string.island_notif_condensed_info_title), @@ -1008,18 +1016,33 @@ fun IslandNotificationSettingsUI( ) Text( text = stringResource( - if (condensed) R.string.island_notif_condensed_info_desc - else R.string.island_notif_peek_style_full_desc, + when (peekStyle) { + SettingsRepository.ISLAND_NOTIF_PEEK_STYLE_COMPACT -> R.string.island_notif_condensed_info_desc + SettingsRepository.ISLAND_NOTIF_PEEK_STYLE_MINIMAL -> + if (viewModel.isIslandCatchUpEnabled.value) R.string.island_notif_peek_style_minimal_catch_up_desc + else R.string.island_notif_peek_style_minimal_desc + else -> R.string.island_notif_peek_style_full_desc + }, ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp), ) SegmentedPicker( - items = listOf(true, false), - selectedItem = condensed, - onItemSelected = { viewModel.setIslandNotifCondensedInfo(it) }, - labelProvider = { if (it) compactLabel else fullLabel }, + items = listOf( + SettingsRepository.ISLAND_NOTIF_PEEK_STYLE_FULL, + SettingsRepository.ISLAND_NOTIF_PEEK_STYLE_COMPACT, + SettingsRepository.ISLAND_NOTIF_PEEK_STYLE_MINIMAL, + ), + selectedItem = peekStyle, + onItemSelected = { viewModel.setIslandNotifPeekStyle(it) }, + labelProvider = { + when (it) { + SettingsRepository.ISLAND_NOTIF_PEEK_STYLE_COMPACT -> compactLabel + SettingsRepository.ISLAND_NOTIF_PEEK_STYLE_MINIMAL -> minimalLabel + else -> fullLabel + } + }, title = R.string.island_notif_condensed_info_title, modifier = Modifier.fillMaxWidth(), ) @@ -1037,6 +1060,7 @@ fun IslandNotificationSettingsUI( HapticUtil.performVirtualKeyHaptic(view) viewModel.setIslandNotifQueue(checked) }, + onSettingsClick = { showQueueSheet = true }, modifier = Modifier.highlight(highlightSetting == "island_notif_queue"), ) @@ -1116,6 +1140,13 @@ fun IslandNotificationSettingsUI( } } + if (showQueueSheet) { + IslandNotificationQueueOptionsBottomSheet( + viewModel = viewModel, + onDismissRequest = { showQueueSheet = false }, + ) + } + if (showFilterAppsSheet) { AppSelectionSheet( title = stringResource(R.string.island_notif_filter_apps_title), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNotificationQueueOptionsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNotificationQueueOptionsBottomSheet.kt new file mode 100644 index 000000000..ef646ef45 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNotificationQueueOptionsBottomSheet.kt @@ -0,0 +1,104 @@ +package com.sameerasw.essentials.ui.features.display.sheets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.island.service.IslandPreviewRequests +import com.sameerasw.essentials.ui.core.cards.IconToggleItem +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker +import com.sameerasw.essentials.ui.core.sheets.EssentialsBottomSheet +import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.viewmodels.MainViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IslandNotificationQueueOptionsBottomSheet( + viewModel: MainViewModel, + onDismissRequest: () -> Unit, +) { + val view = LocalView.current + + EssentialsBottomSheet(onDismissRequest = onDismissRequest) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.island_notif_queue_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), + ) + + RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { + val queueStyle = viewModel.islandNotifQueueStyle.value + val stackLabel = stringResource(R.string.island_notif_queue_style_stack) + val overlapLabel = stringResource(R.string.island_notif_queue_style_overlap) + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall) + .padding(top = 12.dp), + ) { + Text( + text = stringResource(R.string.island_notif_queue_style_title), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Text( + text = stringResource( + if (queueStyle == SettingsRepository.ISLAND_NOTIF_QUEUE_STYLE_OVERLAP) R.string.island_notif_queue_style_overlap_desc + else R.string.island_notif_queue_style_stack_desc, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + SegmentedPicker( + items = listOf( + SettingsRepository.ISLAND_NOTIF_QUEUE_STYLE_STACK, + SettingsRepository.ISLAND_NOTIF_QUEUE_STYLE_OVERLAP, + ), + selectedItem = queueStyle, + onItemSelected = { + viewModel.setIslandNotifQueueStyle(it) + IslandPreviewRequests.showQueue() + }, + labelProvider = { if (it == SettingsRepository.ISLAND_NOTIF_QUEUE_STYLE_OVERLAP) overlapLabel else stackLabel }, + title = R.string.island_notif_queue_style_title, + modifier = Modifier.fillMaxWidth(), + ) + } + + IconToggleItem( + iconRes = R.drawable.rounded_fullscreen_portrait_24, + title = stringResource(R.string.island_notif_queue_inline_title), + description = stringResource(R.string.island_notif_queue_inline_desc), + isChecked = viewModel.isIslandNotifQueueInline.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandNotifQueueInline(checked) + IslandPreviewRequests.showQueue() + }, + ) + } + } + } +} 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 99f679edd..ceacc4d2f 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -260,9 +260,11 @@ class MainViewModel : ViewModel() { val isIslandMediaShowPrevious = mutableStateOf(false) val isIslandMediaShowLike = mutableStateOf(true) val isIslandNotifCompactHeadsUp = mutableStateOf(true) - val isIslandNotifCondensedInfo = mutableStateOf(false) + val islandNotifPeekStyle = mutableStateOf(SettingsRepository.ISLAND_NOTIF_PEEK_STYLE_FULL) val isIslandNotifKeepProgress = mutableStateOf(true) val isIslandNotifQueue = mutableStateOf(true) + val islandNotifQueueStyle = mutableStateOf(SettingsRepository.ISLAND_NOTIF_QUEUE_STYLE_STACK) + val isIslandNotifQueueInline = mutableStateOf(false) val isIslandNotifSkipSilent = mutableStateOf(true) val isIslandNotifFollowDnd = mutableStateOf(true) val isIslandNotifFilterApps = mutableStateOf(false) @@ -916,12 +918,18 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_ISLAND_NOTIF_COMPACT_HEADS_UP -> isIslandNotifCompactHeadsUp.value = settingsRepository.isIslandNotifCompactHeadsUpEnabled() - SettingsRepository.KEY_ISLAND_NOTIF_CONDENSED_INFO -> - isIslandNotifCondensedInfo.value = settingsRepository.isIslandNotifCondensedInfoEnabled() + SettingsRepository.KEY_ISLAND_NOTIF_PEEK_STYLE -> + islandNotifPeekStyle.value = settingsRepository.getIslandNotifPeekStyle() SettingsRepository.KEY_ISLAND_NOTIF_QUEUE -> isIslandNotifQueue.value = settingsRepository.isIslandNotifQueueEnabled() + SettingsRepository.KEY_ISLAND_NOTIF_QUEUE_STYLE -> + islandNotifQueueStyle.value = settingsRepository.getIslandNotifQueueStyle() + + SettingsRepository.KEY_ISLAND_NOTIF_QUEUE_INLINE -> + isIslandNotifQueueInline.value = settingsRepository.isIslandNotifQueueInlineEnabled() + SettingsRepository.KEY_ISLAND_NOTIF_SKIP_SILENT -> isIslandNotifSkipSilent.value = settingsRepository.isIslandNotifSkipSilentEnabled() @@ -2433,9 +2441,11 @@ class MainViewModel : ViewModel() { isIslandMediaShowPrevious.value = settingsRepository.isIslandMediaShowPreviousEnabled() isIslandMediaShowLike.value = settingsRepository.isIslandMediaShowLikeEnabled() isIslandNotifCompactHeadsUp.value = settingsRepository.isIslandNotifCompactHeadsUpEnabled() - isIslandNotifCondensedInfo.value = settingsRepository.isIslandNotifCondensedInfoEnabled() + islandNotifPeekStyle.value = settingsRepository.getIslandNotifPeekStyle() isIslandNotifKeepProgress.value = settingsRepository.isIslandNotifKeepProgressEnabled() isIslandNotifQueue.value = settingsRepository.isIslandNotifQueueEnabled() + islandNotifQueueStyle.value = settingsRepository.getIslandNotifQueueStyle() + isIslandNotifQueueInline.value = settingsRepository.isIslandNotifQueueInlineEnabled() isIslandNotifSkipSilent.value = settingsRepository.isIslandNotifSkipSilentEnabled() isIslandNotifFollowDnd.value = settingsRepository.isIslandNotifFollowDndEnabled() isIslandNotifFilterApps.value = settingsRepository.isIslandNotifFilterAppsEnabled() @@ -5730,6 +5740,16 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandShowNotificationsEnabled(enabled) } + fun setIslandNotifQueueInline(enabled: Boolean) { + isIslandNotifQueueInline.value = enabled + settingsRepository.setIslandNotifQueueInlineEnabled(enabled) + } + + fun setIslandNotifQueueStyle(style: String) { + islandNotifQueueStyle.value = style + settingsRepository.setIslandNotifQueueStyle(style) + } + fun setIslandNotifQueue(enabled: Boolean) { isIslandNotifQueue.value = enabled settingsRepository.setIslandNotifQueueEnabled(enabled) @@ -5779,9 +5799,9 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandNotifCompactHeadsUpEnabled(enabled) } - fun setIslandNotifCondensedInfo(enabled: Boolean) { - isIslandNotifCondensedInfo.value = enabled - settingsRepository.setIslandNotifCondensedInfoEnabled(enabled) + fun setIslandNotifPeekStyle(style: String) { + islandNotifPeekStyle.value = style + settingsRepository.setIslandNotifPeekStyle(style) } fun setIslandCatchUpEnabled(enabled: Boolean) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6611da2ee..827f40872 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3214,6 +3214,10 @@ Compact Full Title on the left, message on the right + Minimal + Only the app or contact icon in the island, no peek. Tap it to expand + Only the app or contact icon in the island, no peek. Stays for the catch up duration. Tap it to expand + Full, compact or minimal notification peeks Show notifications Which notifications Heads up @@ -3225,6 +3229,14 @@ Hide notifications that Do Not Disturb silences Show downloads and other ongoing progress Keep several notifications instead of replacing the current one + Queue style + Stack + Overlap + Waiting notifications stacked in one circle. Past three, the last one shows how many more + Waiting notifications as overlapping icons. Past three, the last one shows how many more + Queue inside the island + Draw waiting notifications inside the island instead of beside it + Show the notification queue as a stack or overlapping icons Tap an expanded notification to open its app Peek song change Keep in catchup when paused diff --git a/app/src/test/java/com/sameerasw/essentials/island/CondensedLineTest.kt b/app/src/test/java/com/sameerasw/essentials/island/CondensedLineTest.kt index db7a6d920..44a4a2200 100644 --- a/app/src/test/java/com/sameerasw/essentials/island/CondensedLineTest.kt +++ b/app/src/test/java/com/sameerasw/essentials/island/CondensedLineTest.kt @@ -13,13 +13,13 @@ class CondensedLineTest { private val cellSize = 28.dp private val edgePad = 12.dp - private fun title(natural: Dp, endInset: Dp = 0.dp) = - condensedTitleWidth(natural, lineWidth, cameraSlot, cellSize, edgePad, endInset) + private fun title(natural: Dp, queueInset: Dp = 0.dp) = + condensedTitleWidth(natural, lineWidth, cameraSlot, cellSize, edgePad, queueInset) // Mirrors CondensedLine: each half is the title width plus the edge padding, with the camera slot between them. - private fun side(natural: Dp, endInset: Dp = 0.dp) = title(natural, endInset) + edgePad + private fun side(natural: Dp, queueInset: Dp = 0.dp) = title(natural, queueInset) + edgePad - private fun pillWidth(natural: Dp, endInset: Dp = 0.dp) = side(natural, endInset) * 2 + cameraSlot + private fun pillWidth(natural: Dp, queueInset: Dp = 0.dp) = side(natural, queueInset) * 2 + cameraSlot @Test fun shortTitleHugsTheText() { assertEquals(60.dp, title(natural = 60.dp)) @@ -39,16 +39,19 @@ class CondensedLineTest { @Test fun longestTitleStillFitsTheLineWidth() = assertTrue(pillWidth(natural = 900.dp) <= lineWidth) - @Test fun rightEdgeStaysInsideHalfTheMaxWidth() { + // The queue sits beside the centred pill (stack = one 40dp circle, overlap = three overlapping circles), plus the gap. + @Test fun queueBesideThePillStaysInsideHalfTheMaxWidth() { listOf(0.dp, 60.dp, 900.dp).forEach { natural -> - listOf(0.dp, 46.dp).forEach { endInset -> - val fromCentre = cameraSlot / 2 + edgePad + title(natural, endInset) - val budget = (lineWidth - endInset) / 2 - assertTrue("natural=$natural endInset=$endInset gave $fromCentre", fromCentre <= budget) + listOf(0.dp, 46.dp, 90.dp).forEach { queueInset -> + val fromCentre = cameraSlot / 2 + edgePad + title(natural, queueInset) + queueInset + assertTrue("natural=$natural queueInset=$queueInset gave $fromCentre", fromCentre <= lineWidth / 2) } } } + @Test fun widerQueueLeavesLessRoomForTheTitle() = + assertTrue(title(natural = 900.dp, queueInset = 90.dp) < title(natural = 900.dp, queueInset = 46.dp)) + @Test fun titleGrowsMonotonicallyWithItsText() { val widths = listOf(0.dp, 30.dp, 60.dp, 140.dp, 900.dp).map { title(it) } assertEquals(widths.sortedBy { it.value }, widths) From bb3aeec3bd03028e5409284109b8857a402f18aa Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 15 Oct 2026 17:19:07 +0530 Subject: [PATCH 3/3] refactor(island): tidy queue animation state, drop comments, shorten strings --- .../data/repository/SettingsRepository.kt | 1 - .../essentials/island/model/IslandModels.kt | 1 - .../notifications/NotificationsPlugin.kt | 1 - .../island/service/IslandCoordinator.kt | 1 - .../island/service/IslandPreviewRequests.kt | 1 - .../essentials/island/ui/CompactTemplate.kt | 2 -- .../essentials/island/ui/IslandStack.kt | 34 +++++++++++++++---- .../essentials/island/ui/LineTemplate.kt | 1 - app/src/main/res/values/strings.xml | 18 +++++----- .../essentials/island/CondensedLineTest.kt | 1 - 10 files changed, 36 insertions(+), 25 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 4fdcdd4e7..9930fdcc2 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 @@ -628,7 +628,6 @@ class SettingsRepository( const val KEY_ISLAND_MEDIA_SHOW_PREVIOUS = "island_media_show_previous" const val KEY_ISLAND_MEDIA_SHOW_LIKE = "island_media_show_like" const val KEY_ISLAND_NOTIF_COMPACT_HEADS_UP = "island_notif_compact_heads_up" - // Legacy boolean (Compact vs Full); read only to seed KEY_ISLAND_NOTIF_PEEK_STYLE. const val KEY_ISLAND_NOTIF_CONDENSED_INFO = "island_notif_condensed_info" const val KEY_ISLAND_NOTIF_PEEK_STYLE = "island_notif_peek_style" const val ISLAND_NOTIF_PEEK_STYLE_FULL = "full" diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt index d214f687a..ffa3e205a 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt @@ -136,7 +136,6 @@ class IslandItem( val bypassLauncherOnly: Boolean = false, val stack: List = emptyList(), - // The waiting part of `stack` in the order it will be shown; null means `stack` minus the current icon. val queued: List? = null, val priorityGroup: String? = 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 1ced4ad90..6c5f0c3a5 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 @@ -284,7 +284,6 @@ class NotificationsPlugin : BaseIslandPlugin() { clearAll() } - // With catch up on, the minimal icon stays for the catch up duration; otherwise for the regular timeout. private fun holdMinimal() { val c = ctx ?: return if (!settings.isIslandCatchUpEnabled()) { 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 00a7f878b..1cad3a81b 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 @@ -573,7 +573,6 @@ class IslandCoordinator( private enum class QueuePreviewLayout { Peek, MinimalInline, MinimalStack } - // Sample notification with two waiting icons, shaped like NotificationsPlugin's output for the given layout. private fun queuePreviewItem(title: String, text: String, layout: QueuePreviewLayout, overlap: Boolean): IslandItem { fun icon(key: String, current: Boolean = false) = StackIcon(key, current = current) { size -> IslandIcon(R.drawable.rounded_notifications_unread_24, size = size * 0.7f) diff --git a/app/src/main/java/com/sameerasw/essentials/island/service/IslandPreviewRequests.kt b/app/src/main/java/com/sameerasw/essentials/island/service/IslandPreviewRequests.kt index f6646a961..17299f997 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/service/IslandPreviewRequests.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/service/IslandPreviewRequests.kt @@ -3,7 +3,6 @@ package com.sameerasw.essentials.island.service import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow -// Lets settings UI ask a running island to briefly show a sample notification queue. object IslandPreviewRequests { private val _queue = MutableSharedFlow(extraBufferCapacity = 1) val queue: SharedFlow = _queue diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt index 0036ad85c..81f58496d 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt @@ -36,9 +36,7 @@ fun CompactTemplate( spec: IslandLayoutSpec, onCellTap: (String) -> Unit, onCellLongPress: (String) -> Unit, - // 0..1 while a side bubble claims the space left of the camera: the sides stop mirroring and hug their own cells. sideBubble: () -> Float = { 0f }, - // Right side minus left side once hugging; the surface shifts by half of it so the camera slot stays on the camera. onSlack: (Int) -> Unit = {}, ) { val cells = buildMap { diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandStack.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandStack.kt index 6919ba069..f78032251 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandStack.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandStack.kt @@ -8,6 +8,9 @@ import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.first import androidx.compose.animation.togetherWith import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeIn @@ -50,13 +53,20 @@ import com.sameerasw.essentials.island.model.StackIcon private const val BUBBLE_MAX_MINIS = 3 private const val PILL_MAX_ICONS = 5 -private class QueueEntry(val icon: StackIcon, val visibility: MutableTransitionState, val leaving: Boolean) +private class QueueEntry( + val icon: StackIcon, + val visibility: MutableTransitionState, + val leaving: Boolean, + val onExited: () -> Unit = {}, +) @Composable private fun rememberQueueEntries(icons: List): List { val transitions = remember { HashMap>() } val previous = remember { arrayListOf() } val settled = remember { booleanArrayOf(false) } + val exits = remember { mutableIntStateOf(0) } + exits.intValue val currentKeys = icons.mapTo(HashSet()) { it.key } val leaving = previous.mapNotNull { icon -> if (icon.key in currentKeys) return@mapNotNull null @@ -66,7 +76,7 @@ private fun rememberQueueEntries(icons: List): List { return@mapNotNull null } state.targetState = false - QueueEntry(icon, state, leaving = true) + QueueEntry(icon, state, leaving = true, onExited = { exits.intValue++ }) } val current = icons.map { icon -> val state = transitions.getOrPut(icon.key) { MutableTransitionState(!settled[0]) } @@ -79,6 +89,15 @@ private fun rememberQueueEntries(icons: List): List { return leaving + current } +@Composable +private fun QueueExitWatcher(entry: QueueEntry) { + if (!entry.leaving) return + LaunchedEffect(entry.visibility) { + snapshotFlow { entry.visibility.isIdle && !entry.visibility.currentState }.first { it } + entry.onExited() + } +} + private val QueueEnter = scaleIn(IslandMotion.expressiveFastSpatial(), initialScale = 0.4f) + fadeIn(IslandMotion.expressiveFastEffects()) private val QueueExit = scaleOut(IslandMotion.expressiveFastEffects(), targetScale = 0.6f) + fadeOut(IslandMotion.expressiveFastEffects()) @@ -104,8 +123,6 @@ fun IslandStackBubble( else -> listOf(0.dp to -r, -r to r * 0.7f, r to r * 0.7f)[i] } - val lastSlot = remember { HashMap>() } - Box( modifier = modifier .size(size) @@ -116,8 +133,10 @@ fun IslandStackBubble( ) { rememberQueueEntries(shown).forEach { entry -> key(entry.icon.key) { + QueueExitWatcher(entry) + val held = remember { arrayOfNulls>(1) } val index = shown.indexOfFirst { it.key == entry.icon.key } - val target = if (index >= 0) slot(index).also { lastSlot[entry.icon.key] = it } else lastSlot[entry.icon.key] ?: (0.dp to 0.dp) + val target = if (index >= 0) slot(index).also { held[0] = it } else held[0] ?: (0.dp to 0.dp) val x by animateDpAsState(target.first, IslandMotion.expressiveDefaultSpatial(), label = "bubbleX") val y by animateDpAsState(target.second, IslandMotion.expressiveDefaultSpatial(), label = "bubbleY") val s by animateDpAsState(iconSize, IslandMotion.expressiveDefaultSpatial(), label = "bubbleSize") @@ -188,13 +207,14 @@ fun IslandStackOverlap( ) { val shown = queueVisible(icons, OVERLAP_MAX) val width by animateDpAsState(stackOverlapWidth(shown.size, size), IslandMotion.expressiveDefaultSpatial(), label = "overlapWidth") - val lastX = remember { HashMap() } Box(modifier.size(width, size)) { val order = shown.asReversed() rememberQueueEntries(order).forEach { entry -> key(entry.icon.key) { + QueueExitWatcher(entry) + val held = remember { arrayOf(0.dp) } val index = order.indexOfFirst { it.key == entry.icon.key } - val target = if (index >= 0) (size * OVERLAP_STEP * index).also { lastX[entry.icon.key] = it } else lastX[entry.icon.key] ?: 0.dp + val target = if (index >= 0) (size * OVERLAP_STEP * index).also { held[0] = it } else held[0] val x by animateDpAsState(target, IslandMotion.expressiveDefaultSpatial(), label = "overlapX") AnimatedVisibility( visibleState = entry.visibility, diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt index af66a5238..2cc615815 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt @@ -146,7 +146,6 @@ private fun CondensedLine(line: LineContent, spec: IslandLayoutSpec, startInset: edgePad = edgePad, queueInset = maxOf(startInset, endInset), ) - // The leading queue sits with the icon; if that side outgrows the title, both sides take its width. val side = maxOf(titleWidth, spec.cellSize + leadingWidth) + edgePad Row( modifier = Modifier.height(spec.compactHeight), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5315460f0..d4fe66e68 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3273,11 +3273,11 @@ App icon and title only. Tap to expand for the content and actions Compact Full - Title on the left, message on the right + Title and message Minimal - Only the app or contact icon in the island, no peek. Tap it to expand - Only the app or contact icon in the island, no peek. Stays for the catch up duration. Tap it to expand - Full, compact or minimal notification peeks + App icon only. Tap to expand + App icon only, kept for the catch up time. Tap to expand + Full, compact or minimal peeks Show notifications Which notifications Heads up @@ -3288,15 +3288,15 @@ Follow Do Not Disturb Hide notifications that Do Not Disturb silences Show downloads and other ongoing progress - Keep several notifications instead of replacing the current one + Stagger notification previews Queue style Stack Overlap - Waiting notifications stacked in one circle. Past three, the last one shows how many more - Waiting notifications as overlapping icons. Past three, the last one shows how many more + One circle, with a count past three + Overlapping icons, with a count past three Queue inside the island - Draw waiting notifications inside the island instead of beside it - Show the notification queue as a stack or overlapping icons + Show the queue inside the island + Stack or overlapping queue icons Tap an expanded notification to open its app Peek song change Keep in catchup when paused diff --git a/app/src/test/java/com/sameerasw/essentials/island/CondensedLineTest.kt b/app/src/test/java/com/sameerasw/essentials/island/CondensedLineTest.kt index 44a4a2200..abefadb3b 100644 --- a/app/src/test/java/com/sameerasw/essentials/island/CondensedLineTest.kt +++ b/app/src/test/java/com/sameerasw/essentials/island/CondensedLineTest.kt @@ -39,7 +39,6 @@ class CondensedLineTest { @Test fun longestTitleStillFitsTheLineWidth() = assertTrue(pillWidth(natural = 900.dp) <= lineWidth) - // The queue sits beside the centred pill (stack = one 40dp circle, overlap = three overlapping circles), plus the gap. @Test fun queueBesideThePillStaysInsideHalfTheMaxWidth() { listOf(0.dp, 60.dp, 900.dp).forEach { natural -> listOf(0.dp, 46.dp, 90.dp).forEach { queueInset ->