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 7d9e1965a..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 @@ -629,8 +629,16 @@ class SettingsRepository( const val KEY_ISLAND_MEDIA_SHOW_LIKE = "island_media_show_like" const val KEY_ISLAND_NOTIF_COMPACT_HEADS_UP = "island_notif_compact_heads_up" 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" @@ -4154,6 +4162,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) @@ -4190,8 +4206,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 b2de7e710..1c2273874 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 @@ -1000,14 +1000,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 e88755a70..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,6 +136,7 @@ class IslandItem( val bypassLauncherOnly: Boolean = false, val stack: List = emptyList(), + 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..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 @@ -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,17 @@ class NotificationsPlugin : BaseIslandPlugin() { clearAll() } + 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 +337,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 +370,7 @@ class NotificationsPlugin : BaseIslandPlugin() { ) }, stack = stack, + queued = queued, ) }, ) @@ -337,8 +378,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 +421,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 +478,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 +508,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 +531,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 d89072540..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 @@ -67,6 +67,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 @@ -473,10 +479,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() } @@ -523,6 +571,38 @@ class IslandCoordinator( compactVisible = false, ) + private enum class QueuePreviewLayout { Peek, MinimalInline, MinimalStack } + + 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 @@ -551,6 +631,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()) @@ -611,6 +693,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) @@ -676,12 +763,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..17299f997 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/service/IslandPreviewRequests.kt @@ -0,0 +1,13 @@ +package com.sameerasw.essentials.island.service + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +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..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 @@ -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,8 @@ fun CompactTemplate( spec: IslandLayoutSpec, onCellTap: (String) -> Unit, onCellLongPress: (String) -> Unit, - leftBias: () -> Int = { 0 }, + sideBubble: () -> Float = { 0f }, + onSlack: (Int) -> Unit = {}, ) { val cells = buildMap { state.items.values.forEach { item -> item.compact.forEach { put(it.key, item.key to it) } } @@ -138,9 +140,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..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 @@ -1,10 +1,19 @@ 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.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 -import androidx.compose.animation.core.tween import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background @@ -13,6 +22,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 +53,54 @@ 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, + 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 + 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, onExited = { exits.intValue++ }) + } + 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 +} + +@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()) + @Composable fun IslandStackBubble( icons: List, @@ -51,7 +109,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 +122,7 @@ 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] } + Box( modifier = modifier .size(size) @@ -72,25 +131,108 @@ 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) { + QueueExitWatcher(entry) + val held = remember { arrayOfNulls>(1) } + val index = shown.indexOfFirst { it.key == entry.icon.key } + 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") + AnimatedVisibility( + visibleState = entry.visibility, + enter = QueueEnter, + exit = QueueExit, + modifier = Modifier.offset(x, y), ) { - Box(Modifier.size(s), contentAlignment = Alignment.Center) { icon.content(s) } + 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") + 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 { held[0] = it } else held[0] + 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(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 +335,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 +360,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 c321cccc0..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 @@ -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,17 +144,21 @@ private fun CondensedLine(line: LineContent, spec: IslandLayoutSpec, startInset: cameraSlotWidth = spec.cameraSlotWidth, cellSize = spec.cellSize, edgePad = edgePad, - endInset = endInset, + queueInset = maxOf(startInset, endInset), ) - val side = titleWidth + edgePad + 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), @@ -139,7 +167,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 @@ -149,6 +177,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)) @@ -156,6 +185,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 575fe3f79..45bda0613 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,18 +997,56 @@ fun IslandNotificationSettingsUI( enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { - 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) - SegmentedPicker( - items = listOf(true, false), - selectedItem = viewModel.isIslandNotifCondensedInfo.value, - onItemSelected = { viewModel.setIslandNotifCondensedInfo(it) }, - labelProvider = { if (it) compactLabel else fullLabel }, - title = R.string.island_notif_condensed_info_title, + val compactLabel = stringResource(R.string.island_notif_peek_style_compact) + val minimalLabel = stringResource(R.string.island_notif_peek_style_minimal) + Column( modifier = Modifier .fillMaxWidth() - .highlight(highlightSetting == "island_notif_condensed_info"), - ) + .background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall) + .padding(top = 12.dp) + .highlight(highlightSetting == "island_notif_peek_style"), + ) { + Text( + text = stringResource(R.string.island_notif_condensed_info_title), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Text( + text = stringResource( + 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( + 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(), + ) + } } } @@ -1015,6 +1060,7 @@ fun IslandNotificationSettingsUI( HapticUtil.performVirtualKeyHaptic(view) viewModel.setIslandNotifQueue(checked) }, + onSettingsClick = { showQueueSheet = true }, modifier = Modifier.highlight(highlightSetting == "island_notif_queue"), ) @@ -1094,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 e634e6390..182db3975 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -261,9 +261,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) @@ -917,12 +919,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() @@ -2435,9 +2443,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() @@ -5737,6 +5747,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) @@ -5786,9 +5806,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 addde7556..d4fe66e68 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3273,6 +3273,11 @@ App icon and title only. Tap to expand for the content and actions Compact Full + Title and message + Minimal + 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 @@ -3283,7 +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 + One circle, with a count past three + Overlapping icons, with a count past three + Queue inside the island + 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 db7a6d920..abefadb3b 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,18 @@ class CondensedLineTest { @Test fun longestTitleStillFitsTheLineWidth() = assertTrue(pillWidth(natural = 900.dp) <= lineWidth) - @Test fun rightEdgeStaysInsideHalfTheMaxWidth() { + @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)