From b3f7790cb34830122bebf5d832f6d64a38d11e14 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Thu, 12 Nov 2026 17:07:10 -0700 Subject: [PATCH 1/7] refactor(island): consolidate battery status logic and update BatteryGlyph rendering --- .../island/plugins/brief/BriefPlugin.kt | 21 +++--- .../plugins/timebattery/BatteryStatus.kt | 70 +++++++++++++++++++ .../plugins/timebattery/TimeBatteryPlugin.kt | 52 +++----------- .../island/ui/components/IslandComponents.kt | 41 ++++++++--- 4 files changed, 116 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/BatteryStatus.kt diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt index ae225d3d4f..73d666af78 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt @@ -18,7 +18,6 @@ import com.sameerasw.essentials.island.ui.SurfaceBackdrop import android.Manifest import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.content.pm.PackageManager import android.content.ContentUris import androidx.compose.foundation.Image @@ -75,7 +74,6 @@ import com.sameerasw.essentials.island.plugins.soften import com.sameerasw.essentials.island.ui.IslandHaptics import com.sameerasw.essentials.translation.TranslatableText import kotlinx.coroutines.launch -import android.os.BatteryManager import android.os.Build import com.sameerasw.essentials.utils.BluetoothBatteryUtils import com.sameerasw.essentials.utils.BluetoothDeviceIcons @@ -123,6 +121,9 @@ import com.sameerasw.essentials.island.plugins.BaseIslandPlugin import com.sameerasw.essentials.island.ui.IslandTextStyles import com.sameerasw.essentials.island.ui.islandTextColor import com.sameerasw.essentials.island.ui.components.BatteryGlyph +import com.sameerasw.essentials.island.plugins.timebattery.batteryIdleColor +import com.sameerasw.essentials.island.plugins.timebattery.batteryStateColor +import com.sameerasw.essentials.island.plugins.timebattery.readBatteryStatus import com.sameerasw.essentials.island.ui.components.BatteryRing import com.sameerasw.essentials.island.ui.components.CameraRow import com.sameerasw.essentials.utils.CalendarEventUtil @@ -442,7 +443,7 @@ private fun BriefOverview( now = System.currentTimeMillis() } } - val battery = remember(now) { readBattery(context) } + val battery = remember(now) { readBatteryStatus(context) } val alarm = remember(now, alarmHours) { alarmHours?.let { NextAlarm.within(context, it) } } val devices by produceState(BriefDevicesCache.devices, now) { val fresh = withContext(Dispatchers.IO) { readDevices(context) } @@ -459,6 +460,7 @@ private fun BriefOverview( val timeSettings = remember { SettingsRepository(context) } val time = IslandTimeFormat.format(context, timeSettings, now) + val batteryColor = battery?.let { batteryStateColor(timeSettings, it) ?: batteryIdleColor(timeSettings) } ?: accent val date = SimpleDateFormat(DateFormat.getBestDateTimePattern(Locale.getDefault(), "EEEMMMd"), Locale.getDefault()).format(Date(now)) val artwork = remember(media?.artwork) { media?.artwork?.asImageBitmap() } @@ -483,11 +485,11 @@ private fun BriefOverview( Spacer(Modifier.width(8.dp)) } } - if (battery >= 0) { + if (battery != null) { if (iconStyle) { - BatteryGlyph(battery, accent, showLevel = true) + BatteryGlyph(battery.level, batteryColor, showLevel = true, charging = battery.charging, powerSave = battery.powerSave, limited = battery.limited) } else { - BatteryRing(battery, accent, showLevel = true) + BatteryRing(battery.level, batteryColor, showLevel = true) } } }, @@ -617,13 +619,6 @@ private fun BriefEventRow(event: UpcomingCalendarEvent, now: Long, accent: Color } } -private fun readBattery(context: Context): Int { - val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) ?: return -1 - val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) - val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100) - return if (level < 0 || scale <= 0) -1 else level * 100 / scale -} - @Composable private fun BriefPlayer(media: MediaSnapshot, artwork: ImageBitmap?, onClick: () -> Unit, modifier: Modifier = Modifier) { val context = LocalContext.current diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/BatteryStatus.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/BatteryStatus.kt new file mode 100644 index 0000000000..dc3d2a3469 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/BatteryStatus.kt @@ -0,0 +1,70 @@ +package com.sameerasw.essentials.island.plugins.timebattery + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Color as AndroidColor +import android.os.BatteryManager +import android.os.PowerManager +import androidx.compose.ui.graphics.Color +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.island.plugins.soften +import com.sameerasw.essentials.utils.battery.ChargeLimitUtil + +data class BatteryStatus( + val level: Int, + val charging: Boolean, + val limited: Boolean, + val powerSave: Boolean, +) + +private val AUTO_CHARGING = AndroidColor.rgb(0, 230, 118) + +fun readBatteryStatus(context: Context, intent: Intent? = null): BatteryStatus? { + val batteryIntent = intent ?: context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) ?: return null + val raw = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, 100) + if (raw < 0 || scale <= 0) return null + val status = batteryIntent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + val level = (raw * 100f / scale).toInt() + val plugged = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL + val limited = ChargeLimitUtil.isShielded(context, level, plugged) + return BatteryStatus( + level = level, + charging = plugged && !limited, + limited = limited, + powerSave = (context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isPowerSaveMode ?: false, + ) +} + +fun batteryStateColor(settings: SettingsRepository, status: BatteryStatus): Color? { + fun parse(hex: String, fallback: Int) = try { + AndroidColor.parseColor(hex) + } catch (_: Exception) { + fallback + } + val charge = settings.getDuoBatteryChargingColor() + val level = status.level + val raw = when { + status.charging && settings.isDuoBatteryChargingColorEnabled() -> + if (charge.equals("auto", ignoreCase = true)) AUTO_CHARGING else parse(charge, AUTO_CHARGING) + status.powerSave && settings.isDuoBatteryPowerSaveColorEnabled() -> + parse(settings.getDuoBatteryPowerSaveColor(), AndroidColor.rgb(255, 152, 0)) + level in 0..settings.getDuoBatteryCriticalLevel() && settings.isDuoBatteryCriticalColorEnabled() -> + parse(settings.getDuoBatteryCriticalColor(), AndroidColor.rgb(244, 67, 54)) + level in 0..settings.getDuoBatteryLowLevel() && settings.isDuoBatteryLowColorEnabled() -> + parse(settings.getDuoBatteryLowColor(), AndroidColor.rgb(255, 235, 59)) + else -> return null + } + return Color(soften(raw)) +} + +fun batteryIdleColor(settings: SettingsRepository): Color? { + if (!settings.isIslandBatteryIdleColorEnabled()) return null + val raw = try { + AndroidColor.parseColor(settings.getIslandBatteryIdleColor()) + } catch (_: Exception) { + AndroidColor.WHITE + } + return Color(soften(raw)) +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/TimeBatteryPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/TimeBatteryPlugin.kt index ca090cc1ed..802fa0f0f6 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/TimeBatteryPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/TimeBatteryPlugin.kt @@ -17,8 +17,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.graphics.Color as AndroidColor -import android.os.BatteryManager import android.provider.AlarmClock import android.os.PowerManager import android.text.format.DateFormat @@ -35,7 +33,6 @@ import com.sameerasw.essentials.island.model.IslandPriority import com.sameerasw.essentials.island.model.LineContent import com.sameerasw.essentials.island.model.PluginRequest import com.sameerasw.essentials.island.plugins.BaseIslandPlugin -import com.sameerasw.essentials.island.plugins.soften import com.sameerasw.essentials.island.ui.components.BatteryGlyph import com.sameerasw.essentials.island.ui.components.BatteryRing import com.sameerasw.essentials.island.ui.components.IslandIcon @@ -203,16 +200,11 @@ class TimeBatteryPlugin : BaseIslandPlugin() { } private fun readBattery(intent: Intent?) { - val batteryIntent = intent ?: context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) ?: return - val raw = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) - val scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, 100) - if (raw < 0 || scale <= 0) return - val status = batteryIntent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) - level = (raw * 100f / scale).toInt() - val plugged = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL - shielded = com.sameerasw.essentials.utils.battery.ChargeLimitUtil.isShielded(context, level, plugged) - charging = plugged && !shielded - powerSave = (context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isPowerSaveMode ?: false + val status = readBatteryStatus(context, intent) ?: return + level = status.level + shielded = status.limited + charging = status.charging + powerSave = status.powerSave } private fun severity(): Int = when { @@ -316,7 +308,7 @@ class TimeBatteryPlugin : BaseIslandPlugin() { LineContent( icon = { when { - iconStyle -> BatteryGlyph(batteryLevel, displayColor ?: islandAccent(), showLevel = false) + iconStyle -> BatteryGlyph(batteryLevel, displayColor ?: islandAccent(), showLevel = false, charging = isCharging, powerSave = isSaving, limited = isLimited) else -> BatteryRing(batteryLevel, displayColor ?: islandAccent(), showLevel = false) } }, @@ -345,39 +337,11 @@ class TimeBatteryPlugin : BaseIslandPlugin() { } } - private fun idleColor(): Color? { - if (!settings.isIslandBatteryIdleColorEnabled()) return null - val raw = try { - AndroidColor.parseColor(settings.getIslandBatteryIdleColor()) - } catch (_: Exception) { - AndroidColor.WHITE - } - return Color(soften(raw)) - } + private fun idleColor(): Color? = batteryIdleColor(settings) - private fun stateColor(): Color? { - fun parse(hex: String, fallback: Int) = try { - AndroidColor.parseColor(hex) - } catch (_: Exception) { - fallback - } - val charge = settings.getDuoBatteryChargingColor() - val raw = when { - charging && settings.isDuoBatteryChargingColorEnabled() -> - if (charge.equals("auto", ignoreCase = true)) AUTO_CHARGING else parse(charge, AUTO_CHARGING) - powerSave && settings.isDuoBatteryPowerSaveColorEnabled() -> - parse(settings.getDuoBatteryPowerSaveColor(), AndroidColor.rgb(255, 152, 0)) - level in 0..settings.getDuoBatteryCriticalLevel() && settings.isDuoBatteryCriticalColorEnabled() -> - parse(settings.getDuoBatteryCriticalColor(), AndroidColor.rgb(244, 67, 54)) - level in 0..settings.getDuoBatteryLowLevel() && settings.isDuoBatteryLowColorEnabled() -> - parse(settings.getDuoBatteryLowColor(), AndroidColor.rgb(255, 235, 59)) - else -> return null - } - return Color(soften(raw)) - } + private fun stateColor(): Color? = batteryStateColor(settings, BatteryStatus(level, charging, shielded, powerSave)) private companion object { const val BATTERY_KEY = "battery" - val AUTO_CHARGING = AndroidColor.rgb(0, 230, 118) } } diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt index 3c64532ede..cee4d84e89 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt @@ -12,7 +12,10 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -64,7 +67,9 @@ import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset import com.sameerasw.essentials.island.state.IslandStillness import com.sameerasw.essentials.island.ui.IslandFontFamily import com.sameerasw.essentials.island.ui.IslandMotion @@ -337,7 +342,7 @@ fun BatteryGlyph( if (!showLevel) return@drawBehind paint.typeface = typeface paint.color = labelColor.toArgb() - paint.textSize = size.height * (if (label.length > 2) 0.82f else 0.94f) + paint.textSize = size.height * (if (label.length > 2) 0.74f else 0.84f) paint.letterSpacing = -0.04f paint.getTextBounds(label, 0, label.length, bounds) val textWidth = paint.measureText(label) @@ -360,21 +365,26 @@ fun BatteryGlyph( if (symbol != 0) { Canvas( Modifier - .then(if (symbol == 3) Modifier else Modifier.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)) .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) val overlap = 7.dp.roundToPx() + val placeable = measurable.measure(constraints.offset(horizontal = overlap)) layout((placeable.width - overlap).coerceAtLeast(0), placeable.height) { placeable.place(-overlap, 0) } } - .then(if (symbol == 3) Modifier.size(9.dp, 11.dp) else Modifier.size(11.dp, 13.dp)), + .then(if (symbol == 2) Modifier.size(11.dp, 13.dp) else Modifier.size(9.dp, 11.dp)), ) { val stroke = 1.8.dp.toPx() val gap = 0.8.dp.toPx() + val outline = stroke * 0.7f + val cutout = { path: Path -> + drawPath(path, Color.Black, style = Stroke(gap * 3 + outline, join = StrokeJoin.Round), blendMode = BlendMode.Clear) + drawPath(path, Color.Black, blendMode = BlendMode.Clear) + drawPath(path, Color.White, style = Stroke(outline, join = StrokeJoin.Round)) + } if (symbol == 2) { val arm = size.width * 0.42f val c = center for ((width, paintColor, mode) in listOf( - Triple(stroke + gap * 2, Color.Black, BlendMode.Clear), + Triple(stroke + gap * 3, Color.Black, BlendMode.Clear), Triple(stroke, Color.White, BlendMode.SrcOver), )) { drawLine(paintColor, Offset(c.x - arm, c.y), Offset(c.x + arm, c.y), width, StrokeCap.Round, blendMode = mode) @@ -392,10 +402,7 @@ fun BatteryGlyph( lineTo(w * 0.06f, h * 0.2f) close() } - val outline = stroke * 0.7f - drawPath(shield, Color.Black, style = Stroke(gap * 3 + outline, join = StrokeJoin.Round), blendMode = BlendMode.Clear) - drawPath(shield, Color.Black, blendMode = BlendMode.Clear) - drawPath(shield, Color.White, style = Stroke(outline, join = StrokeJoin.Round)) + cutout(shield) clipRect(right = w * 0.5f) { drawPath(shield, Color.White) } } else { val w = size.width @@ -409,11 +416,23 @@ fun BatteryGlyph( lineTo(w * 0.54f, h * 0.40f) close() } - drawPath(bolt, Color.Black, style = Stroke(gap * 2, join = StrokeJoin.Round), blendMode = BlendMode.Clear) - drawPath(bolt, Color.White, style = Stroke(stroke * 0.3f, join = StrokeJoin.Round)) + cutout(bolt) drawPath(bolt, Color.White) } } } } } + +@Preview(backgroundColor = 0xFF000000, showBackground = true) +@Composable +private fun BatteryGlyphPreview() { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + BatteryGlyph(87, Color(0xFF3DDC84)) + BatteryGlyph(87, Color(0xFF3DDC84), charging = true) + BatteryGlyph(45, Color(0xFFFFC107), powerSave = true) + BatteryGlyph(80, Color(0xFF3DDC84), limited = true) + BatteryGlyph(100, Color(0xFF3DDC84), charging = true) + BatteryGlyph(15, Color(0xFFFF5252), showLevel = false, charging = true) + } +} From a9f24ddd2a3d8fabe8172ce079bc0dd516b388d4 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 13 Nov 2026 21:19:44 +0530 Subject: [PATCH 2/7] fix(island): show battery state symbol in ring and read battery via receiver in Brief --- .../island/plugins/brief/BriefPlugin.kt | 21 +++++- .../plugins/timebattery/TimeBatteryPlugin.kt | 4 +- .../island/ui/components/IslandComponents.kt | 73 ++++++++++++++----- 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt index 73d666af78..379d7305e8 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt @@ -16,7 +16,11 @@ import com.sameerasw.essentials.island.remote.LocalRemotePaused import com.sameerasw.essentials.island.remote.RemoteExpanded import com.sameerasw.essentials.island.ui.SurfaceBackdrop import android.Manifest +import android.content.BroadcastReceiver import android.content.Context +import android.content.IntentFilter +import android.os.PowerManager +import androidx.compose.runtime.DisposableEffect import android.content.Intent import android.content.pm.PackageManager import android.content.ContentUris @@ -121,6 +125,7 @@ import com.sameerasw.essentials.island.plugins.BaseIslandPlugin import com.sameerasw.essentials.island.ui.IslandTextStyles import com.sameerasw.essentials.island.ui.islandTextColor import com.sameerasw.essentials.island.ui.components.BatteryGlyph +import com.sameerasw.essentials.island.plugins.timebattery.BatteryStatus import com.sameerasw.essentials.island.plugins.timebattery.batteryIdleColor import com.sameerasw.essentials.island.plugins.timebattery.batteryStateColor import com.sameerasw.essentials.island.plugins.timebattery.readBatteryStatus @@ -443,7 +448,18 @@ private fun BriefOverview( now = System.currentTimeMillis() } } - val battery = remember(now) { readBatteryStatus(context) } + var batteryState by remember { mutableStateOf(null) } + DisposableEffect(context) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(c: Context, intent: Intent) { + batteryState = readBatteryStatus(context, if (intent.action == Intent.ACTION_BATTERY_CHANGED) intent else null) + } + } + val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED).apply { addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) } + val sticky = context.registerReceiver(receiver, filter) + batteryState = readBatteryStatus(context, sticky) + onDispose { context.unregisterReceiver(receiver) } + } val alarm = remember(now, alarmHours) { alarmHours?.let { NextAlarm.within(context, it) } } val devices by produceState(BriefDevicesCache.devices, now) { val fresh = withContext(Dispatchers.IO) { readDevices(context) } @@ -458,6 +474,7 @@ private fun BriefOverview( } } + val battery = batteryState val timeSettings = remember { SettingsRepository(context) } val time = IslandTimeFormat.format(context, timeSettings, now) val batteryColor = battery?.let { batteryStateColor(timeSettings, it) ?: batteryIdleColor(timeSettings) } ?: accent @@ -489,7 +506,7 @@ private fun BriefOverview( if (iconStyle) { BatteryGlyph(battery.level, batteryColor, showLevel = true, charging = battery.charging, powerSave = battery.powerSave, limited = battery.limited) } else { - BatteryRing(battery.level, batteryColor, showLevel = true) + BatteryRing(battery.level, batteryColor, showLevel = true, charging = battery.charging, powerSave = battery.powerSave, limited = battery.limited) } } }, diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/TimeBatteryPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/TimeBatteryPlugin.kt index 802fa0f0f6..75e785237f 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/TimeBatteryPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/TimeBatteryPlugin.kt @@ -299,7 +299,7 @@ class TimeBatteryPlugin : BaseIslandPlugin() { CompactCell("battery") { when { iconStyle -> BatteryGlyph(batteryLevel, displayColor ?: islandAccent(), showLevel = showLevel, charging = isCharging, powerSave = isSaving, limited = isLimited) - else -> BatteryRing(batteryLevel, displayColor ?: islandAccent(), showLevel = showLevel) + else -> BatteryRing(batteryLevel, displayColor ?: islandAccent(), showLevel = showLevel, charging = isCharging, powerSave = isSaving, limited = isLimited) } }, ), @@ -309,7 +309,7 @@ class TimeBatteryPlugin : BaseIslandPlugin() { icon = { when { iconStyle -> BatteryGlyph(batteryLevel, displayColor ?: islandAccent(), showLevel = false, charging = isCharging, powerSave = isSaving, limited = isLimited) - else -> BatteryRing(batteryLevel, displayColor ?: islandAccent(), showLevel = false) + else -> BatteryRing(batteryLevel, displayColor ?: islandAccent(), showLevel = false, charging = isCharging, powerSave = isSaving, limited = isLimited) } }, start = "$batteryLevel%", diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt index cee4d84e89..3ef827da5b 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt @@ -269,7 +269,22 @@ fun EqualizerBars( } @Composable -fun BatteryRing(level: Int, color: Color, modifier: Modifier = Modifier, showLevel: Boolean = false, size: Dp = if (showLevel) 24.dp else 18.dp) { +fun BatteryRing( + level: Int, + color: Color, + modifier: Modifier = Modifier, + showLevel: Boolean = false, + size: Dp = if (showLevel) 24.dp else 18.dp, + charging: Boolean = false, + powerSave: Boolean = false, + limited: Boolean = false, +) { + val symbol = when { + limited -> 3 + charging -> 1 + powerSave -> 2 + else -> 0 + } val sweep by animateFloatAsState(360f * level.coerceIn(0, 100) / 100f, IslandMotion.float(), label = "batterySweep") val ringColor by animateColorAsState(color, label = "batteryColor") var ringPx by remember { mutableStateOf(0) } @@ -285,7 +300,21 @@ fun BatteryRing(level: Int, color: Color, modifier: Modifier = Modifier, showLev drawArc(ringColor, -90f, sweep, false, topLeft, arcSize, style = Stroke(stroke, cap = StrokeCap.Round)) } } - if (showLevel && ringPx > 0) { + if (symbol != 0) { + Canvas(Modifier.fillMaxSize(0.46f)) { + when (symbol) { + 1 -> drawPath(batteryBoltPath(this.size.width, this.size.height), ringColor) + 3 -> drawPath(batteryShieldPath(this.size.width, this.size.height), ringColor) + else -> { + val arm = this.size.width * 0.42f + val c = center + val w = this.size.width * 0.2f + drawLine(ringColor, Offset(c.x - arm, c.y), Offset(c.x + arm, c.y), w, StrokeCap.Round) + drawLine(ringColor, Offset(c.x, c.y - arm), Offset(c.x, c.y + arm), w, StrokeCap.Round) + } + } + } + } else if (showLevel && ringPx > 0) { val label = level.coerceIn(0, 100).toString() val innerPx = ringPx * (1f - 2f * 0.12f) * 0.8f val fontPx = minOf(innerPx / (label.length * 0.62f), innerPx * 0.75f) @@ -393,29 +422,13 @@ fun BatteryGlyph( } else if (symbol == 3) { val w = size.width val h = size.height - val shield = Path().apply { - moveTo(w * 0.5f, h * 0.04f) - lineTo(w * 0.94f, h * 0.2f) - lineTo(w * 0.94f, h * 0.5f) - cubicTo(w * 0.94f, h * 0.76f, w * 0.74f, h * 0.92f, w * 0.5f, h * 0.98f) - cubicTo(w * 0.26f, h * 0.92f, w * 0.06f, h * 0.76f, w * 0.06f, h * 0.5f) - lineTo(w * 0.06f, h * 0.2f) - close() - } + val shield = batteryShieldPath(w, h) cutout(shield) clipRect(right = w * 0.5f) { drawPath(shield, Color.White) } } else { val w = size.width val h = size.height - val bolt = Path().apply { - moveTo(w * 0.62f, 0f) - lineTo(w * 0.08f, h * 0.58f) - lineTo(w * 0.46f, h * 0.58f) - lineTo(w * 0.36f, h) - lineTo(w * 0.92f, h * 0.40f) - lineTo(w * 0.54f, h * 0.40f) - close() - } + val bolt = batteryBoltPath(w, h) cutout(bolt) drawPath(bolt, Color.White) } @@ -424,6 +437,26 @@ fun BatteryGlyph( } } +private fun batteryShieldPath(w: Float, h: Float) = Path().apply { + moveTo(w * 0.5f, h * 0.04f) + lineTo(w * 0.94f, h * 0.2f) + lineTo(w * 0.94f, h * 0.5f) + cubicTo(w * 0.94f, h * 0.76f, w * 0.74f, h * 0.92f, w * 0.5f, h * 0.98f) + cubicTo(w * 0.26f, h * 0.92f, w * 0.06f, h * 0.76f, w * 0.06f, h * 0.5f) + lineTo(w * 0.06f, h * 0.2f) + close() +} + +private fun batteryBoltPath(w: Float, h: Float) = Path().apply { + moveTo(w * 0.62f, 0f) + lineTo(w * 0.08f, h * 0.58f) + lineTo(w * 0.46f, h * 0.58f) + lineTo(w * 0.36f, h) + lineTo(w * 0.92f, h * 0.40f) + lineTo(w * 0.54f, h * 0.40f) + close() +} + @Preview(backgroundColor = 0xFF000000, showBackground = true) @Composable private fun BatteryGlyphPreview() { From 3e024c0413e418a6be1776cc5649a43e390b303c Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 13 Nov 2026 21:31:42 +0530 Subject: [PATCH 3/7] fix: weird scroll down prevention on shorter notificaitons in island --- .../java/com/sameerasw/essentials/island/ui/ExpandedHost.kt | 5 +++++ .../essentials/island/ui/components/ExpandedResize.kt | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/ExpandedHost.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/ExpandedHost.kt index 93a54823eb..0636294aac 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/ExpandedHost.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/ExpandedHost.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange @@ -58,6 +59,9 @@ fun ExpandedHost( propagateMinConstraints = true, ) { val scrollState = rememberScrollState() + LaunchedEffect(IslandLiveResize.active) { + if (!IslandLiveResize.active) scrollState.scrollTo(0) + } Box( if (capped) Modifier.edgeAwareScroll(scrollState) else Modifier, propagateMinConstraints = true, @@ -78,6 +82,7 @@ internal fun Modifier.edgeAwareScroll(state: ScrollState): Modifier = if (change.isConsumed || !change.pressed) break val delta = change.positionChange() total += delta + if (IslandLiveResize.active) break if (!decided && total.getDistance() > viewConfiguration.touchSlop) { decided = true handle = abs(total.y) > abs(total.x) && diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/components/ExpandedResize.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/components/ExpandedResize.kt index 4a558939e0..6034abd4af 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/components/ExpandedResize.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/components/ExpandedResize.kt @@ -56,7 +56,9 @@ fun IslandExpandedScope.rememberExpandedResize(prefKey: String): ExpandedResize val limit = remember(prefKey) { limits.getOrPut(prefKey) { mutableFloatStateOf(settings.getFloat(prefKey, 0f)) } } - val screenCap = (LocalConfiguration.current.screenHeightDp * 0.8f).dp + val screenCap = (LocalConfiguration.current.screenHeightDp * 0.8f).dp.let { + if (spec.maxExpandedHeight != Dp.Unspecified) minOf(it, spec.maxExpandedHeight) else it + } val minHeight = spec.compactHeight + spec.expandedTopPadding + 96.dp val maxHeight = limit.floatValue.takeIf { it > 0f }?.dp?.coerceIn(minHeight, screenCap) ?: screenCap val measured = remember { IntArray(1) } From 738138e3c5faae0463f0c8bd2362033569d74508 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 13 Nov 2026 22:22:52 +0530 Subject: [PATCH 4/7] refactor: adjust bubble gap and pill spacing in IslandRoot UI --- .../java/com/sameerasw/essentials/island/ui/IslandRoot.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 50d9e0451e..52739dfb51 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 @@ -677,7 +677,7 @@ private fun IslandRootContent( } fun bubbleSide(): Float = bubbleSideAnim.value fun balanceSign(): Float = if (spec.balanceCompact && spec.growDirection == 0) 1f - 2f * bubbleSide() else 1f - val bubbleGapPx = with(density) { spec.contentGap.roundToPx() } + val bubbleGapPx = with(density) { (spec.contentGap + 4.dp).roundToPx() } fun sideBubbleProgress(): Float = if (spec.growDirection == 0 && (hasCompactCells || stage == IslandStage.Hidden)) catchUpAnim.value.coerceIn(0f, 1f) else 0f val slackAnim = remember { Animatable(slackTarget.toFloat()) } @@ -939,8 +939,7 @@ private fun IslandRootContent( } } } - LiquidBridges(liquidField) - val pillGapPx = with(density) { 2.dp.roundToPx() } + val pillGapPx = with(density) { 8.dp.roundToPx() } AnimatedVisibility( visible = queueShown, enter = if (stage == IslandStage.Expanded) EnterTransition.None else fadeIn(IslandMotion.contentIn()), From 47efc150168147d4c1ba5c1280be0ca546fe37a8 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 13 Nov 2026 22:25:50 +0530 Subject: [PATCH 5/7] feat: allow changing island time priority --- .../sameerasw/essentials/island/model/IslandPriorityEntries.kt | 1 + .../essentials/island/plugins/timebattery/TimeBatteryPlugin.kt | 1 + .../sameerasw/essentials/ui/features/display/IslandSettingsUI.kt | 1 - 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt index 7dc058de59..944aa76e35 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt @@ -4,6 +4,7 @@ object IslandPriorityEntries { class Entry(val id: String, val pluginIds: Set, val defaultPriority: Int) val all = listOf( + Entry("time", setOf("time"), IslandPriority.TIME), Entry("battery", setOf("battery"), IslandPriority.BATTERY_ALERT), Entry("live_updates", setOf("progress"), IslandPriority.PROGRESS), Entry("notifications", setOf("notifications"), IslandPriority.NOTIFICATION), diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/TimeBatteryPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/TimeBatteryPlugin.kt index 75e785237f..d4b8f2c35b 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/TimeBatteryPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/timebattery/TimeBatteryPlugin.kt @@ -265,6 +265,7 @@ class TimeBatteryPlugin : BaseIslandPlugin() { val timeItem = IslandItem( key = "time", priority = IslandPriority.TIME, + priorityGroup = "time", placement = CompactPlacement.Pinned, needsCompanyAtCenter = settings.isIslandBatteryIconConditional(), chip = settings.getBoolean(SettingsRepository.KEY_ISLAND_TIME_CHIP, false), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index 004fa19bf1..4bb73c61af 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -523,7 +523,6 @@ fun IslandSettingsUI( } } renderEntry("calls", null) - renderEntry("time", null) ReorderableColumn( list = if (reorderMode) workingOrder else savedOrder, onSettle = { from, to -> workingOrder = workingOrder.toMutableList().apply { add(to, removeAt(from)) } }, From bc78fb464afdfe061b45765a88bb62847f9e3863 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 13 Nov 2026 22:42:50 +0530 Subject: [PATCH 6/7] feat: add carousel view for brief plugin items and integrate with devices batter --- .../plugins/brief/BriefItemsCarousel.kt | 96 +++++++++++++++++++ .../island/plugins/brief/BriefPlugin.kt | 88 ++++++++--------- .../island/remote/RemoteBriefCards.kt | 2 +- .../island/state/IslandController.kt | 1 + .../island/state/IslandItemsState.kt | 19 ++++ 5 files changed, 155 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefItemsCarousel.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/island/state/IslandItemsState.kt diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefItemsCarousel.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefItemsCarousel.kt new file mode 100644 index 0000000000..22621465ef --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefItemsCarousel.kt @@ -0,0 +1,96 @@ +package com.sameerasw.essentials.island.plugins.brief + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel +import androidx.compose.material3.carousel.rememberCarouselState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.Dp +import com.sameerasw.essentials.island.ui.LocalIslandTextColor +import com.sameerasw.essentials.island.ui.components.LocalIslandChipInk +import com.sameerasw.essentials.island.ui.components.LocalIslandIconColor +import androidx.compose.ui.unit.dp + +internal class BriefCarouselItem( + val key: String, + val onClick: (() -> Unit)?, + val container: Color? = null, + val content: @Composable () -> Unit, +) + +private val PillWidth = 96.dp +private val PillGap = 6.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BriefItemsCarousel( + entries: List, + height: Dp, + modifier: Modifier = Modifier, +) { + BoxWithConstraints(modifier.fillMaxWidth().height(height)) { + val fits = PillWidth * entries.size + PillGap * (entries.size - 1).coerceAtLeast(0) <= maxWidth + if (fits) { + Row( + Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(PillGap, Alignment.CenterHorizontally), + ) { + entries.forEach { entry -> CarouselPill(entry, Modifier.width(PillWidth).fillMaxHeight()) } + } + } else { + val state = rememberCarouselState { entries.size } + HorizontalMultiBrowseCarousel( + state = state, + modifier = Modifier.fillMaxSize(), + preferredItemWidth = PillWidth, + itemSpacing = PillGap, + contentPadding = PaddingValues(0.dp), + ) { index -> + CarouselPill(entries[index], Modifier.fillMaxSize().maskClip(RoundedCornerShape(50))) + } + } + } +} + +@Composable +private fun CarouselPill(entry: BriefCarouselItem, modifier: Modifier) { + val container = entry.container?.takeIf { it.luminance() <= 0.4f } + val ink = if (container != null) Color.White else null + Box( + modifier + .clip(RoundedCornerShape(50)) + .background(container ?: Color.Black) + .clickable(enabled = entry.onClick != null) { entry.onClick?.invoke() } + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center, + ) { + CompositionLocalProvider( + LocalIslandIconColor provides ink, + LocalIslandTextColor provides ink, + LocalIslandChipInk provides ink, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + entry.content() + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt index 379d7305e8..f3867bc949 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt @@ -126,6 +126,8 @@ import com.sameerasw.essentials.island.ui.IslandTextStyles import com.sameerasw.essentials.island.ui.islandTextColor import com.sameerasw.essentials.island.ui.components.BatteryGlyph import com.sameerasw.essentials.island.plugins.timebattery.BatteryStatus +import com.sameerasw.essentials.island.state.IslandItemEntry +import com.sameerasw.essentials.island.state.IslandItemsState import com.sameerasw.essentials.island.plugins.timebattery.batteryIdleColor import com.sameerasw.essentials.island.plugins.timebattery.batteryStateColor import com.sameerasw.essentials.island.plugins.timebattery.readBatteryStatus @@ -181,6 +183,7 @@ class BriefPlugin : BaseIslandPlugin() { BriefExpanded( scope, iconStyle, devicesIconStyle, alarmHours, calendarEnabled, calendarIds, showAllDay, showGlow, twoLineHeader, onOpenLivePlayer = { ctx?.request?.invoke(PluginRequest.Expand(MediaPlugin.ITEM_KEY)) }, + onRequestExpand = { ctx?.request?.invoke(PluginRequest.Expand(it)) }, ) { open -> pageOpen = open } @@ -208,6 +211,7 @@ private fun BriefExpanded( showGlow: Boolean, twoLineHeader: Boolean, onOpenLivePlayer: () -> Unit, + onRequestExpand: (String) -> Unit, onPageOpenChanged: ((() -> Unit)?) -> Unit, ) { val context = LocalContext.current @@ -259,6 +263,10 @@ private fun BriefExpanded( onEventClick = { page = BriefPage.Event(it) }, onPlayerClick = { if (IslandMediaState.live.value) onOpenLivePlayer() else page = BriefPage.Player }, onCardClick = { page = BriefPage.Remote(it) }, + onItemClick = { entry -> + val item = entry.item + if (item.expanded != null) onRequestExpand(item.key) else item.onOpen?.invoke() + }, ) is BriefPage.Event -> SwipeBackPage(scope, onBack = { page = BriefPage.Overview }) { BriefEventDetail(target.event, showGlow, scope) @@ -433,13 +441,19 @@ private fun BriefOverview( onEventClick: (UpcomingCalendarEvent) -> Unit, onPlayerClick: () -> Unit, onCardClick: (String) -> Unit, + onItemClick: (IslandItemEntry) -> Unit, ) { val context = LocalContext.current val spec = scope.spec + val allItemEntries by IslandItemsState.entries.collectAsState() val sidePadding = spec.expandedPadding * 0.6f + spec.expandedCorner * 0.25f val accent = islandAccent() val cardsByPackage by RemoteBriefCards.cards.collectAsState() val remoteCards = remember(cardsByPackage) { cardsByPackage.values.flatten() } + val itemEntries = remember(allItemEntries, cardsByPackage) { + val briefPackages = cardsByPackage.filterValues { it.isNotEmpty() }.keys + allItemEntries.filter { entry -> briefPackages.none { entry.item.key.startsWith("$it:") } } + } var now by remember { mutableLongStateOf(System.currentTimeMillis()) } LaunchedEffect(Unit) { @@ -523,16 +537,31 @@ private fun BriefOverview( } val blockPadding = sidePadding - val cardsBlock: @Composable () -> Unit = { - remoteCards.forEach { card -> + val carouselItems = itemEntries.map { entry -> + BriefCarouselItem(entry.item.key, { onItemClick(entry) }, entry.item.accent ?: accent) { + entry.item.compact.forEach { it.content() } + } + } + devices.map { device -> + BriefCarouselItem(device.key, null) { + IslandIcon(device.iconRes, tint = accent, size = 18.dp) + Spacer(Modifier.width(6.dp)) + if (devicesIconStyle) { + BatteryGlyph(device.level, accent, showLevel = true) + } else { + BatteryRing(device.level, accent, showLevel = true) + } + } + } + val itemsBlock: @Composable () -> Unit = { + if (carouselItems.isNotEmpty()) { Spacer(Modifier.height(12.dp)) - RemoteBriefCardRow(card, { if (card.hasPage) onCardClick(card.key) }, Modifier.padding(horizontal = blockPadding)) + BriefItemsCarousel(carouselItems, spec.compactHeight, Modifier.padding(horizontal = blockPadding)) } } - val devicesBlock: @Composable () -> Unit = { - if (devices.isNotEmpty()) { + val cardsBlock: @Composable () -> Unit = { + remoteCards.forEach { card -> Spacer(Modifier.height(12.dp)) - BriefDeviceRows(devices, devicesIconStyle, accent, Modifier.fillMaxWidth().padding(horizontal = blockPadding)) + RemoteBriefCardRow(card, { if (card.hasPage) onCardClick(card.key) }, Modifier.padding(horizontal = blockPadding)) } } val calendarBlock: @Composable () -> Unit = { @@ -560,16 +589,12 @@ private fun BriefOverview( BriefPlayer(media, artwork, onPlayerClick, Modifier.padding(horizontal = blockPadding)) } } + itemsBlock() if (spec.landscape) { val landscapeItems = buildList<@Composable () -> Unit> { remoteCards.forEach { card -> add { RemoteBriefCardRow(card, { if (card.hasPage) onCardClick(card.key) }) } } - if (devices.isNotEmpty()) { - add { - BriefDeviceRows(devices, devicesIconStyle, accent, Modifier.fillMaxWidth()) - } - } if (calendarEnabled) { val list = events when { @@ -590,7 +615,6 @@ private fun BriefOverview( playerBlock() } else { cardsBlock() - devicesBlock() calendarBlock() playerBlock() } @@ -607,7 +631,7 @@ private fun BriefEventRow(event: UpcomingCalendarEvent, now: Long, accent: Color modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) - .background(Color.White.copy(alpha = 0.1f)) + .background(Color.Black) .clickable(onClick = onClick) .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -787,7 +811,7 @@ private fun BriefAlarmChip(time: String, accent: Color) { Row( modifier = Modifier .clip(RoundedCornerShape(50)) - .background(Color.White.copy(alpha = 0.1f)) + .background(Color.Black) .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -797,42 +821,6 @@ private fun BriefAlarmChip(time: String, accent: Color) { } } -@Composable -private fun BriefDeviceRows(devices: List, iconStyle: Boolean, accent: Color, modifier: Modifier = Modifier) { - val rows = if (devices.size > 4) devices.chunked((devices.size + 1) / 2) else listOf(devices) - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) { - rows.forEach { row -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), - ) { - val single = row.size == 1 - row.forEach { device -> BriefDeviceChip(device, iconStyle, accent, if (single) Modifier else Modifier.weight(1f)) } - } - } - } -} - -@Composable -private fun BriefDeviceChip(device: BriefDevice, iconStyle: Boolean, accent: Color, modifier: Modifier = Modifier) { - Row( - modifier = modifier - .clip(RoundedCornerShape(50)) - .background(Color.White.copy(alpha = 0.1f)) - .padding(horizontal = 10.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - IslandIcon(device.iconRes, tint = accent, size = 18.dp) - Spacer(Modifier.width(6.dp)) - if (iconStyle) { - BatteryGlyph(device.level, accent, showLevel = true) - } else { - BatteryRing(device.level, accent, showLevel = true) - } - } -} - @Composable private fun BalancedColumns(modifier: Modifier = Modifier, rowGap: Dp, columnGap: Dp, content: @Composable () -> Unit) { Layout(content = content, modifier = modifier) { measurables, constraints -> diff --git a/app/src/main/java/com/sameerasw/essentials/island/remote/RemoteBriefCards.kt b/app/src/main/java/com/sameerasw/essentials/island/remote/RemoteBriefCards.kt index e51a6ee599..e02fc35952 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/remote/RemoteBriefCards.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/remote/RemoteBriefCards.kt @@ -56,7 +56,7 @@ fun RemoteBriefCardRow(card: RemoteBriefCard, onClick: () -> Unit, modifier: Mod modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) - .background(Color.White.copy(alpha = 0.1f)) + .background(Color.Black) .clickable(onClick = onClick) .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt index bd7b1ae306..0418c62a16 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt @@ -423,6 +423,7 @@ class IslandController( val bubble = merged ?: bubbleOwner?.sideBubble val bubbleKey = if (merged != null) MERGED_BUBBLE_KEY else bubbleOwner?.key _state.value = IslandUiState(stage, items, arrangement, focusedKey, bubble, bubbleKey) + IslandItemsState.publish(items, arrangement.visibleItems + listOfNotNull(bubbleOwner?.key, mediaItem?.key.takeIf { mediaBubble != null })) if (previous != stage) onStageChanged?.invoke(stage) } diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/IslandItemsState.kt b/app/src/main/java/com/sameerasw/essentials/island/state/IslandItemsState.kt new file mode 100644 index 0000000000..8b6e40da60 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/state/IslandItemsState.kt @@ -0,0 +1,19 @@ +package com.sameerasw.essentials.island.state + +import com.sameerasw.essentials.island.model.IslandItem +import kotlinx.coroutines.flow.MutableStateFlow + +class IslandItemEntry(val item: IslandItem, val shown: Boolean) + +object IslandItemsState { + val entries = MutableStateFlow>(emptyList()) + + fun publish(items: Map, shownKeys: Set) { + val candidates = items.values.filter { it.key !in EXCLUDED && (it.compactVisible || it.line != null) } + val shown = candidates.filter { it.key in shownKeys }.sortedBy { it.effectivePriority } + val hidden = candidates.filter { it.key !in shownKeys }.sortedBy { it.effectivePriority } + entries.value = shown.map { IslandItemEntry(it, true) } + hidden.map { IslandItemEntry(it, false) } + } + + private val EXCLUDED = setOf("brief", "time", "battery", "alarm", "media", "notifications") +} From 16b71f075efdd433759784de18dec6f929bdfefe Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 13 Nov 2026 22:49:05 +0530 Subject: [PATCH 7/7] feat: add island item presence state --- .../island/state/IslandController.kt | 4 ++- .../island/state/IslandItemsState.kt | 16 ++++++++++ .../ui/core/cards/IconToggleItem.kt | 3 ++ .../ui/features/display/IslandSettingsUI.kt | 30 ++++++++++++++++++- 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt index 0418c62a16..d12cc6faeb 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt @@ -423,7 +423,9 @@ class IslandController( val bubble = merged ?: bubbleOwner?.sideBubble val bubbleKey = if (merged != null) MERGED_BUBBLE_KEY else bubbleOwner?.key _state.value = IslandUiState(stage, items, arrangement, focusedKey, bubble, bubbleKey) - IslandItemsState.publish(items, arrangement.visibleItems + listOfNotNull(bubbleOwner?.key, mediaItem?.key.takeIf { mediaBubble != null })) + val shownKeys = arrangement.visibleItems + listOfNotNull(bubbleOwner?.key, mediaItem?.key.takeIf { mediaBubble != null }, focusedKey) + IslandItemsState.publish(items, shownKeys) + IslandItemsState.publishPresence(itemsBySource, shownKeys) if (previous != stage) onStageChanged?.invoke(stage) } diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/IslandItemsState.kt b/app/src/main/java/com/sameerasw/essentials/island/state/IslandItemsState.kt index 8b6e40da60..d6eb4b6423 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/state/IslandItemsState.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/state/IslandItemsState.kt @@ -3,11 +3,27 @@ package com.sameerasw.essentials.island.state import com.sameerasw.essentials.island.model.IslandItem import kotlinx.coroutines.flow.MutableStateFlow +enum class IslandPresence { Shown, Hidden } + class IslandItemEntry(val item: IslandItem, val shown: Boolean) object IslandItemsState { val entries = MutableStateFlow>(emptyList()) + val presence = MutableStateFlow>(emptyMap()) + + fun publishPresence(itemsBySource: Map>, shownKeys: Set) { + val result = HashMap() + itemsBySource.forEach { (source, items) -> + items.forEach { item -> + val group = item.priorityGroup ?: source + if (item.key in shownKeys) result[group] = IslandPresence.Shown + else if (group !in result) result[group] = IslandPresence.Hidden + } + } + if (presence.value != result) presence.value = result + } + fun publish(items: Map, shownKeys: Set) { val candidates = items.values.filter { it.key !in EXCLUDED && (it.compactVisible || it.line != null) } val shown = candidates.filter { it.key in shownKeys }.sortedBy { it.effectivePriority } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/core/cards/IconToggleItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/core/cards/IconToggleItem.kt index 86a5888ffc..3afc633271 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/core/cards/IconToggleItem.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/core/cards/IconToggleItem.kt @@ -55,6 +55,8 @@ import com.sameerasw.essentials.translation.ui.TranslationBottomSheet import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenu import com.sameerasw.essentials.utils.HapticUtil +val LocalIconToggleStatus = androidx.compose.runtime.compositionLocalOf<(@Composable () -> Unit)?> { null } + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun IconToggleItem( @@ -327,6 +329,7 @@ fun IconToggleItem( ) } } + LocalIconToggleStatus.current?.invoke() if (onSettingsClick != null && enabled && finalIsChecked) { IconButton( onClick = { diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index 4bb73c61af..db5eb7eec8 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -43,6 +43,14 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import com.sameerasw.essentials.ui.core.cards.LocalIconToggleStatus +import com.sameerasw.essentials.island.state.IslandPresence +import com.sameerasw.essentials.island.state.IslandItemsState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.border import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -256,7 +264,7 @@ fun IslandSettingsUI( spacing = 2.dp, cornerRadius = 24.dp, ) { - val renderEntry: @Composable (String, (@Composable () -> Unit)?) -> Unit = { entryId, handle -> + val renderEntryBody: @Composable (String, (@Composable () -> Unit)?) -> Unit = { entryId, handle -> when (entryId) { "calls" -> IconToggleItem( dragHandle = handle, @@ -522,6 +530,14 @@ fun IslandSettingsUI( } } } + val presenceMap by IslandItemsState.presence.collectAsState() + val renderEntry: @Composable (String, (@Composable () -> Unit)?) -> Unit = { entryId, handle -> + val groups = if (entryId == "calls") setOf("call") else IslandPriorityEntries.entries(providerEntries).firstOrNull { it.id == entryId }?.pluginIds.orEmpty() + val presence = groups.mapNotNull { presenceMap[it] }.let { found -> + found.firstOrNull { it == IslandPresence.Shown } ?: found.firstOrNull() + } + CompositionLocalProvider(LocalIconToggleStatus provides { IslandPresenceDot(presence) }) { renderEntryBody(entryId, handle) } + } renderEntry("calls", null) ReorderableColumn( list = if (reorderMode) workingOrder else savedOrder, @@ -823,3 +839,15 @@ fun IslandSettingsUI( ) } } + +@Composable +private fun IslandPresenceDot(presence: IslandPresence?) { + val color = MaterialTheme.colorScheme.primary + Box(Modifier.size(10.dp), contentAlignment = Alignment.Center) { + when (presence) { + IslandPresence.Shown -> Box(Modifier.size(8.dp).background(color, CircleShape)) + IslandPresence.Hidden -> Box(Modifier.size(8.dp).border(1.5.dp, color, CircleShape)) + null -> Unit + } + } +}