feat(widget): Add 1x1 size support to battery widget

Introduce BatteryLayout enum with size-driven dispatch. Drops minWidth from 80dp to 40dp so the battery widget can be placed at one cell. At 1-cell-wide placements render a compact icon+percent stack; wider sizes keep the current NARROW/WIDE layouts.
This commit is contained in:
darken
2026-04-23 16:11:07 +02:00
committed by Matthias Urhahn
parent 016914ec05
commit 118eaa8d08
8 changed files with 401 additions and 201 deletions
@@ -158,7 +158,7 @@ class AncGlanceWidget : GlanceAppWidget() {
private fun getCellsForSize(size: Int): Int { private fun getCellsForSize(size: Int): Int {
var n = 2 var n = 2
while (70 * n - 30 < size) { while (70 * n - 30 <= size) {
++n ++n
} }
return n - 1 return n - 1
@@ -57,12 +57,14 @@ class BatteryGlanceWidget : GlanceAppWidget() {
} catch (e: Exception) { } catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" } log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
provideContent { provideContent {
val layout = BatteryLayout.forCells(getCellsForSize(LocalSize.current.width.value.toInt()))
GlanceWidgetContent( GlanceWidgetContent(
state = WidgetRenderState.Message( state = WidgetRenderState.Message(
theme = WidgetTheme.DEFAULT, theme = WidgetTheme.DEFAULT,
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT), resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT), resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT), resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
layout = layout,
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label), primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
), ),
context = context, context = context,
@@ -77,6 +79,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList()) val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null) val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
val widthDp = LocalSize.current.width val widthDp = LocalSize.current.width
val layout = BatteryLayout.forCells(getCellsForSize(widthDp.value.toInt()))
val state = try { val state = try {
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId) val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
@@ -93,8 +96,6 @@ class BatteryGlanceWidget : GlanceAppWidget() {
profiles.firstOrNull { it.id == pid }?.label profiles.firstOrNull { it.id == pid }?.label
} }
val isWide = getCellsForSize(widthDp.value.toInt()) >= 5
WidgetRenderStateMapper.map( WidgetRenderStateMapper.map(
context = context, context = context,
device = device, device = device,
@@ -102,7 +103,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
isPro = isPro, isPro = isPro,
hasConfiguredProfile = profileId != null, hasConfiguredProfile = profileId != null,
profileLabel = profileLabel, profileLabel = profileLabel,
isWide = isWide, layout = layout,
) )
} catch (e: Exception) { } catch (e: Exception) {
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" } log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
@@ -111,6 +112,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT), resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT), resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT), resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
layout = layout,
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label), primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
) )
} }
@@ -137,7 +139,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
*/ */
private fun getCellsForSize(size: Int): Int { private fun getCellsForSize(size: Int): Int {
var n = 2 var n = 2
while (70 * n - 30 < size) { while (70 * n - 30 <= size) {
++n ++n
} }
return n - 1 return n - 1
@@ -25,6 +25,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import eu.darken.capod.R import eu.darken.capod.R
@@ -87,86 +88,51 @@ private fun DualPodPreview(
val iconColor = Color(state.resolvedIconColor) val iconColor = Color(state.resolvedIconColor)
val iconTint = ColorFilter.tint(iconColor) val iconTint = ColorFilter.tint(iconColor)
if (state.isWide) { when (state.layout) {
// Wide layout: left | case | right in a horizontal row BatteryLayout.WIDE -> {
WidgetContainer(bgColor = bgColor, modifier = modifier) { WidgetContainer(bgColor = bgColor, modifier = modifier) {
Row( Row(
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Left pod PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
PodItemRow( PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
icon = state.leftIcon, PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40)
percent = state.leftPercent, }
charging = state.leftCharging, DeviceLabel(
inEar = state.leftInEar, label = state.deviceLabel,
visible = state.theme.showDeviceLabel,
textColor = textColor, textColor = textColor,
iconTint = iconTint, modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
iconSize = 40,
modifier = Modifier.padding(end = 12.dp),
)
// Case
PodItemRow(
icon = state.caseIcon,
percent = state.casePercent,
charging = state.caseCharging,
inEar = false,
textColor = textColor,
iconTint = iconTint,
iconSize = 40,
modifier = Modifier.padding(end = 12.dp),
)
// Right pod
PodItemRow(
icon = state.rightIcon,
percent = state.rightPercent,
charging = state.rightCharging,
inEar = state.rightInEar,
textColor = textColor,
iconTint = iconTint,
iconSize = 40,
) )
} }
DeviceLabel(
label = state.deviceLabel,
visible = state.theme.showDeviceLabel,
textColor = textColor,
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
)
} }
} else {
// Compact layout: vertical stack BatteryLayout.NARROW -> {
WidgetContainer(bgColor = bgColor, modifier = modifier) { WidgetContainer(bgColor = bgColor, modifier = modifier) {
PodItemRow( PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint)
icon = state.leftIcon, PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint)
percent = state.leftPercent, PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
charging = state.leftCharging, DeviceLabel(
inEar = state.leftInEar, label = state.deviceLabel,
textColor = textColor, visible = state.theme.showDeviceLabel,
iconTint = iconTint, textColor = textColor,
) )
PodItemRow( }
icon = state.rightIcon, }
percent = state.rightPercent,
charging = state.rightCharging, BatteryLayout.TINY_COLUMN -> {
inEar = state.rightInEar, WidgetContainer(
textColor = textColor, bgColor = bgColor,
iconTint = iconTint, modifier = modifier,
) horizontalPadding = 4.dp,
PodItemRow( verticalPadding = 0.dp,
icon = state.caseIcon, ) {
percent = state.casePercent, TinyPodItem(state.leftIcon, state.leftPercent, textColor, iconTint)
charging = state.caseCharging, TinyPodItem(state.rightIcon, state.rightPercent, textColor, iconTint)
inEar = false, TinyPodItem(state.caseIcon, state.casePercent, textColor, iconTint)
textColor = textColor, }
iconTint = iconTint,
)
DeviceLabel(
label = state.deviceLabel,
visible = state.theme.showDeviceLabel,
textColor = textColor,
)
} }
} }
} }
@@ -180,45 +146,60 @@ private fun SinglePodPreview(
val textColor = Color(state.resolvedTextColor) val textColor = Color(state.resolvedTextColor)
val iconTint = ColorFilter.tint(Color(state.resolvedIconColor)) val iconTint = ColorFilter.tint(Color(state.resolvedIconColor))
WidgetContainer(bgColor = bgColor, modifier = modifier) { when (state.layout) {
Row( BatteryLayout.WIDE, BatteryLayout.NARROW -> {
horizontalArrangement = Arrangement.Center, WidgetContainer(bgColor = bgColor, modifier = modifier) {
verticalAlignment = Alignment.CenterVertically, Row(
) { horizontalArrangement = Arrangement.Center,
Image( verticalAlignment = Alignment.CenterVertically,
painter = painterResource(state.batteryIcon), ) {
contentDescription = null, Image(
modifier = Modifier.size(20.dp), painter = painterResource(state.batteryIcon),
colorFilter = iconTint, contentDescription = null,
) modifier = Modifier.size(20.dp),
Text( colorFilter = iconTint,
text = formatPercent(state.percent.toBatteryOrNull()), )
fontSize = 12.sp, Text(
color = textColor, text = formatPercent(state.percent.toBatteryOrNull()),
modifier = Modifier.padding(horizontal = 8.dp), fontSize = 12.sp,
) color = textColor,
if (state.charging) { modifier = Modifier.padding(horizontal = 8.dp),
Image( )
painter = painterResource(R.drawable.ic_baseline_power_24), if (state.charging) {
contentDescription = null, Image(
modifier = Modifier.size(20.dp), painter = painterResource(R.drawable.ic_baseline_power_24),
colorFilter = iconTint, contentDescription = null,
) modifier = Modifier.size(20.dp),
} colorFilter = iconTint,
if (state.worn) { )
Image( }
painter = painterResource(R.drawable.ic_baseline_hearing_24), if (state.worn) {
contentDescription = null, Image(
modifier = Modifier.size(20.dp), painter = painterResource(R.drawable.ic_baseline_hearing_24),
colorFilter = iconTint, contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
}
DeviceLabel(
label = state.deviceLabel,
visible = state.theme.showDeviceLabel,
textColor = textColor,
) )
} }
} }
DeviceLabel(
label = state.deviceLabel, BatteryLayout.TINY_COLUMN -> {
visible = state.theme.showDeviceLabel, WidgetContainer(
textColor = textColor, bgColor = bgColor,
) modifier = modifier,
horizontalPadding = 4.dp,
verticalPadding = 0.dp,
) {
TinyPodItem(state.batteryIcon, state.percent, textColor, iconTint)
}
}
} }
} }
@@ -229,17 +210,25 @@ private fun MessagePreview(
) { ) {
val bgColor = Color(state.resolvedBgColor) val bgColor = Color(state.resolvedBgColor)
val textColor = Color(state.resolvedTextColor) val textColor = Color(state.resolvedTextColor)
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
WidgetContainer(bgColor = bgColor, modifier = modifier) { WidgetContainer(
bgColor = bgColor,
modifier = modifier,
horizontalPadding = if (isCompact) 4.dp else 16.dp,
verticalPadding = if (isCompact) 0.dp else 4.dp,
) {
Text( Text(
text = state.primaryText, text = state.primaryText,
fontSize = 12.sp, fontSize = if (isCompact) 10.sp else 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor, color = textColor,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = if (isCompact) 2 else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
if (state.secondaryText != null) { if (!isCompact && state.secondaryText != null) {
Text( Text(
text = state.secondaryText, text = state.secondaryText,
fontSize = 12.sp, fontSize = 12.sp,
@@ -260,11 +249,17 @@ private fun LoadingPreview(
) { ) {
val bgColor = Color(state.resolvedBgColor) val bgColor = Color(state.resolvedBgColor)
val textColor = Color(state.resolvedTextColor) val textColor = Color(state.resolvedTextColor)
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
WidgetContainer(bgColor = bgColor, modifier = modifier) { WidgetContainer(
bgColor = bgColor,
modifier = modifier,
horizontalPadding = if (isCompact) 4.dp else 16.dp,
verticalPadding = if (isCompact) 0.dp else 4.dp,
) {
Text( Text(
text = "", text = "",
fontSize = 12.sp, fontSize = if (isCompact) 10.sp else 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor, color = textColor,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -277,13 +272,15 @@ private fun LoadingPreview(
private fun WidgetContainer( private fun WidgetContainer(
bgColor: Color, bgColor: Color,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
horizontalPadding: Dp = 16.dp,
verticalPadding: Dp = 4.dp,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Column( Column(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(bgColor) .background(bgColor)
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
@@ -338,6 +335,34 @@ private fun PodItemRow(
} }
} }
@Composable
private fun TinyPodItem(
icon: Int,
percent: Float,
textColor: Color,
iconTint: ColorFilter,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(14.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(percent.toBatteryOrNull()),
fontSize = 12.sp,
color = textColor,
maxLines = 1,
modifier = Modifier.padding(start = 2.dp),
)
}
}
@Composable @Composable
private fun DeviceLabel( private fun DeviceLabel(
label: String?, label: String?,
@@ -363,12 +388,30 @@ private fun formatPercent(percent: Float?): String {
@Preview2 @Preview2
@Composable @Composable
private fun PreviewDualCompact() = PreviewWrapper { private fun PreviewDualTiny11() = PreviewWrapper {
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod()) ComposeWidgetPreview(
state = WidgetRenderState.previewDualPod(layout = BatteryLayout.TINY_COLUMN),
modifier = Modifier.size(40.dp),
)
}
@Preview2
@Composable
private fun PreviewDualTinyTall() = PreviewWrapper {
ComposeWidgetPreview(
state = WidgetRenderState.previewDualPod(layout = BatteryLayout.TINY_COLUMN),
modifier = Modifier.size(width = 40.dp, height = 110.dp),
)
}
@Preview2
@Composable
private fun PreviewDualNarrow() = PreviewWrapper {
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(layout = BatteryLayout.NARROW))
} }
@Preview2 @Preview2
@Composable @Composable
private fun PreviewDualWide() = PreviewWrapper { private fun PreviewDualWide() = PreviewWrapper {
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(isWide = true)) ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(layout = BatteryLayout.WIDE))
} }
@@ -3,6 +3,7 @@ package eu.darken.capod.main.ui.widget
import android.content.Intent import android.content.Intent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter import androidx.glance.ColorFilter
@@ -53,31 +54,47 @@ private fun GlanceDualPod(
state: WidgetRenderState.DualPod, state: WidgetRenderState.DualPod,
clickModifier: GlanceModifier, clickModifier: GlanceModifier,
) { ) {
val textStyle = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
)
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor)) val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
if (state.isWide) { when (state.layout) {
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) { BatteryLayout.WIDE -> {
Row( val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp), GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
horizontalAlignment = Alignment.CenterHorizontally, Row(
verticalAlignment = Alignment.CenterVertically, modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp),
) { horizontalAlignment = Alignment.CenterHorizontally,
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp)) verticalAlignment = Alignment.CenterVertically,
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp)) ) {
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40) GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40)
}
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
} }
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
} }
} else {
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) { BatteryLayout.NARROW -> {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint) val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint) GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint) GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor) GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
}
}
BatteryLayout.TINY_COLUMN -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(
bgColor = state.resolvedBgColor,
clickModifier = clickModifier,
horizontalPadding = 4.dp,
verticalPadding = 0.dp,
) {
GlanceTinyPodItem(state.leftIcon, state.leftPercent, textStyle, iconTint)
GlanceTinyPodItem(state.rightIcon, state.rightPercent, textStyle, iconTint)
GlanceTinyPodItem(state.caseIcon, state.casePercent, textStyle, iconTint)
}
} }
} }
} }
@@ -87,47 +104,60 @@ private fun GlanceSinglePod(
state: WidgetRenderState.SinglePod, state: WidgetRenderState.SinglePod,
clickModifier: GlanceModifier, clickModifier: GlanceModifier,
) { ) {
val textStyle = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
)
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor)) val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) { when (state.layout) {
Row( BatteryLayout.WIDE, BatteryLayout.NARROW -> {
modifier = GlanceModifier.fillMaxWidth(), val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
horizontalAlignment = Alignment.CenterHorizontally, GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
verticalAlignment = Alignment.CenterVertically, Row(
) { modifier = GlanceModifier.fillMaxWidth(),
Image( horizontalAlignment = Alignment.CenterHorizontally,
provider = ImageProvider(state.batteryIcon), verticalAlignment = Alignment.CenterVertically,
contentDescription = null, ) {
modifier = GlanceModifier.size(20.dp), Image(
colorFilter = iconTint, provider = ImageProvider(state.batteryIcon),
) contentDescription = null,
Text( modifier = GlanceModifier.size(20.dp),
text = formatGlancePercent(state.percent.toBatteryOrNull()), colorFilter = iconTint,
style = textStyle, )
modifier = GlanceModifier.padding(horizontal = 8.dp), Text(
) text = formatGlancePercent(state.percent.toBatteryOrNull()),
if (state.charging) { style = textStyle,
Image( modifier = GlanceModifier.padding(horizontal = 8.dp),
provider = ImageProvider(R.drawable.ic_baseline_power_24), )
contentDescription = null, if (state.charging) {
modifier = GlanceModifier.size(20.dp), Image(
colorFilter = iconTint, provider = ImageProvider(R.drawable.ic_baseline_power_24),
) contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
if (state.worn) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
}
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
} }
if (state.worn) { }
Image(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24), BatteryLayout.TINY_COLUMN -> {
contentDescription = null, val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
modifier = GlanceModifier.size(20.dp), GlanceWidgetRoot(
colorFilter = iconTint, bgColor = state.resolvedBgColor,
) clickModifier = clickModifier,
horizontalPadding = 4.dp,
verticalPadding = 0.dp,
) {
GlanceTinyPodItem(state.batteryIcon, state.percent, textStyle, iconTint)
} }
} }
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
} }
} }
@@ -136,20 +166,29 @@ private fun GlanceMessage(
state: WidgetRenderState.Message, state: WidgetRenderState.Message,
clickModifier: GlanceModifier, clickModifier: GlanceModifier,
) { ) {
val textStyle = TextStyle( val isCompact = state.layout == BatteryLayout.TINY_COLUMN
color = fixedColor(state.resolvedTextColor), val fontSize = if (isCompact) 10.sp else 12.sp
fontSize = 12.sp, val hPad = if (isCompact) 4.dp else 16.dp
fontWeight = FontWeight.Bold, val vPad = if (isCompact) 0.dp else 4.dp
textAlign = TextAlign.Center,
)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) { GlanceWidgetRoot(
bgColor = state.resolvedBgColor,
clickModifier = clickModifier,
horizontalPadding = hPad,
verticalPadding = vPad,
) {
Text( Text(
text = state.primaryText, text = state.primaryText,
style = textStyle, style = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = fontSize,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier.fillMaxWidth(), modifier = GlanceModifier.fillMaxWidth(),
maxLines = if (isCompact) 2 else Int.MAX_VALUE,
) )
if (state.secondaryText != null) { if (!isCompact && state.secondaryText != null) {
Text( Text(
text = state.secondaryText, text = state.secondaryText,
style = TextStyle( style = TextStyle(
@@ -169,12 +208,18 @@ private fun GlanceLoading(
state: WidgetRenderState.Loading, state: WidgetRenderState.Loading,
clickModifier: GlanceModifier, clickModifier: GlanceModifier,
) { ) {
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) { val isCompact = state.layout == BatteryLayout.TINY_COLUMN
GlanceWidgetRoot(
bgColor = state.resolvedBgColor,
clickModifier = clickModifier,
horizontalPadding = if (isCompact) 4.dp else 16.dp,
verticalPadding = if (isCompact) 0.dp else 4.dp,
) {
Text( Text(
text = "", text = "",
style = TextStyle( style = TextStyle(
color = fixedColor(state.resolvedTextColor), color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp, fontSize = if (isCompact) 10.sp else 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
), ),
@@ -187,13 +232,15 @@ private fun GlanceLoading(
private fun GlanceWidgetRoot( private fun GlanceWidgetRoot(
bgColor: Int, bgColor: Int,
clickModifier: GlanceModifier, clickModifier: GlanceModifier,
horizontalPadding: Dp = 16.dp,
verticalPadding: Dp = 4.dp,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Column( Column(
modifier = clickModifier modifier = clickModifier
.fillMaxSize() .fillMaxSize()
.background(fixedColor(bgColor)) .background(fixedColor(bgColor))
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -246,6 +293,33 @@ private fun GlancePodItem(
} }
} }
@Composable
private fun GlanceTinyPodItem(
icon: Int,
percent: Float,
textStyle: TextStyle,
iconTint: ColorFilter,
modifier: GlanceModifier = GlanceModifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size(14.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent.toBatteryOrNull()),
style = textStyle,
maxLines = 1,
modifier = GlanceModifier.padding(start = 4.dp),
)
}
}
@Composable @Composable
private fun GlanceDeviceLabel( private fun GlanceDeviceLabel(
label: String?, label: String?,
@@ -4,18 +4,33 @@ import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import eu.darken.capod.R import eu.darken.capod.R
enum class BatteryLayout {
TINY_COLUMN,
NARROW,
WIDE;
companion object {
fun forCells(widthCells: Int): BatteryLayout = when {
widthCells <= 1 -> TINY_COLUMN
widthCells >= 5 -> WIDE
else -> NARROW
}
}
}
sealed class WidgetRenderState { sealed class WidgetRenderState {
abstract val theme: WidgetTheme abstract val theme: WidgetTheme
@get:ColorInt abstract val resolvedBgColor: Int @get:ColorInt abstract val resolvedBgColor: Int
@get:ColorInt abstract val resolvedTextColor: Int @get:ColorInt abstract val resolvedTextColor: Int
@get:ColorInt abstract val resolvedIconColor: Int @get:ColorInt abstract val resolvedIconColor: Int
abstract val layout: BatteryLayout
data class DualPod( data class DualPod(
override val theme: WidgetTheme, override val theme: WidgetTheme,
@ColorInt override val resolvedBgColor: Int, @ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int, @ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int, @ColorInt override val resolvedIconColor: Int,
val isWide: Boolean, override val layout: BatteryLayout,
val deviceLabel: String?, val deviceLabel: String?,
@DrawableRes val leftIcon: Int, @DrawableRes val leftIcon: Int,
val leftPercent: Float, val leftPercent: Float,
@@ -35,6 +50,7 @@ sealed class WidgetRenderState {
@ColorInt override val resolvedBgColor: Int, @ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int, @ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int, @ColorInt override val resolvedIconColor: Int,
override val layout: BatteryLayout,
val deviceLabel: String?, val deviceLabel: String?,
@DrawableRes val headsetIcon: Int, @DrawableRes val headsetIcon: Int,
val percent: Float, val percent: Float,
@@ -48,6 +64,7 @@ sealed class WidgetRenderState {
@ColorInt override val resolvedBgColor: Int, @ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int, @ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int, @ColorInt override val resolvedIconColor: Int,
override val layout: BatteryLayout,
val primaryText: String, val primaryText: String,
val secondaryText: String? = null, val secondaryText: String? = null,
) : WidgetRenderState() ) : WidgetRenderState()
@@ -57,6 +74,7 @@ sealed class WidgetRenderState {
@ColorInt override val resolvedBgColor: Int, @ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int, @ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int, @ColorInt override val resolvedIconColor: Int,
override val layout: BatteryLayout,
) : WidgetRenderState() ) : WidgetRenderState()
companion object { companion object {
@@ -65,13 +83,13 @@ sealed class WidgetRenderState {
@ColorInt bgColor: Int = 0xFFFFFFFF.toInt(), @ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
@ColorInt textColor: Int = 0xFF1E1E1E.toInt(), @ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
@ColorInt iconColor: Int = 0xFF1E1E1E.toInt(), @ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
isWide: Boolean = false, layout: BatteryLayout = BatteryLayout.NARROW,
): DualPod = DualPod( ): DualPod = DualPod(
theme = theme, theme = theme,
resolvedBgColor = bgColor, resolvedBgColor = bgColor,
resolvedTextColor = textColor, resolvedTextColor = textColor,
resolvedIconColor = iconColor, resolvedIconColor = iconColor,
isWide = isWide, layout = layout,
deviceLabel = "My AirPods Pro", deviceLabel = "My AirPods Pro",
leftIcon = R.drawable.device_airpods_pro2_left, leftIcon = R.drawable.device_airpods_pro2_left,
leftPercent = 0.85f, leftPercent = 0.85f,
@@ -85,5 +103,25 @@ sealed class WidgetRenderState {
casePercent = 1.0f, casePercent = 1.0f,
caseCharging = false, caseCharging = false,
) )
fun previewSinglePod(
theme: WidgetTheme = WidgetTheme.DEFAULT,
@ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
@ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
@ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
layout: BatteryLayout = BatteryLayout.NARROW,
): SinglePod = SinglePod(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
layout = layout,
deviceLabel = "My AirPods Max",
headsetIcon = R.drawable.device_airpods_max,
percent = 0.72f,
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
charging = false,
worn = true,
)
} }
} }
@@ -18,7 +18,7 @@ object WidgetRenderStateMapper {
isPro: Boolean, isPro: Boolean,
hasConfiguredProfile: Boolean, hasConfiguredProfile: Boolean,
profileLabel: String?, profileLabel: String?,
isWide: Boolean = false, layout: BatteryLayout = BatteryLayout.NARROW,
): WidgetRenderState { ): WidgetRenderState {
val bgColor = resolvedBgColor(context, theme) val bgColor = resolvedBgColor(context, theme)
val textColor = resolvedTextColor(context, theme) val textColor = resolvedTextColor(context, theme)
@@ -30,6 +30,7 @@ object WidgetRenderStateMapper {
resolvedBgColor = bgColor, resolvedBgColor = bgColor,
resolvedTextColor = textColor, resolvedTextColor = textColor,
resolvedIconColor = iconColor, resolvedIconColor = iconColor,
layout = layout,
primaryText = context.getString(R.string.upgrade_capod_label), primaryText = context.getString(R.string.upgrade_capod_label),
secondaryText = context.getString(R.string.upgrade_capod_description), secondaryText = context.getString(R.string.upgrade_capod_description),
) )
@@ -39,7 +40,7 @@ object WidgetRenderStateMapper {
resolvedBgColor = bgColor, resolvedBgColor = bgColor,
resolvedTextColor = textColor, resolvedTextColor = textColor,
resolvedIconColor = iconColor, resolvedIconColor = iconColor,
isWide = isWide, layout = layout,
deviceLabel = profileLabel ?: device.getLabel(context), deviceLabel = profileLabel ?: device.getLabel(context),
leftIcon = device.leftPodIcon, leftIcon = device.leftPodIcon,
leftPercent = device.batteryLeft.toBatteryFloat(), leftPercent = device.batteryLeft.toBatteryFloat(),
@@ -59,6 +60,7 @@ object WidgetRenderStateMapper {
resolvedBgColor = bgColor, resolvedBgColor = bgColor,
resolvedTextColor = textColor, resolvedTextColor = textColor,
resolvedIconColor = iconColor, resolvedIconColor = iconColor,
layout = layout,
deviceLabel = profileLabel ?: device.getLabel(context), deviceLabel = profileLabel ?: device.getLabel(context),
headsetIcon = device.iconRes, headsetIcon = device.iconRes,
percent = device.batteryHeadset.toBatteryFloat(), percent = device.batteryHeadset.toBatteryFloat(),
@@ -72,6 +74,7 @@ object WidgetRenderStateMapper {
resolvedBgColor = bgColor, resolvedBgColor = bgColor,
resolvedTextColor = textColor, resolvedTextColor = textColor,
resolvedIconColor = iconColor, resolvedIconColor = iconColor,
layout = layout,
primaryText = context.getString(R.string.pods_unknown_label), primaryText = context.getString(R.string.pods_unknown_label),
) )
@@ -86,6 +89,7 @@ object WidgetRenderStateMapper {
resolvedBgColor = bgColor, resolvedBgColor = bgColor,
resolvedTextColor = textColor, resolvedTextColor = textColor,
resolvedIconColor = iconColor, resolvedIconColor = iconColor,
layout = layout,
primaryText = context.getString(messageRes), primaryText = context.getString(messageRes),
) )
} }
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:minWidth="80dp" android:minWidth="40dp"
android:minHeight="40dp" android:minHeight="40dp"
android:targetCellWidth="2" android:targetCellWidth="2"
android:targetCellHeight="1" android:targetCellHeight="1"
@@ -0,0 +1,39 @@
package eu.darken.capod.main.ui.widget
import eu.darken.capod.main.ui.widget.BatteryLayout.NARROW
import eu.darken.capod.main.ui.widget.BatteryLayout.TINY_COLUMN
import eu.darken.capod.main.ui.widget.BatteryLayout.WIDE
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class BatteryLayoutTest : BaseTest() {
@Test fun `1 cell wide resolves to tiny column`() {
BatteryLayout.forCells(1) shouldBe TINY_COLUMN
}
@Test fun `2 cells wide resolves to narrow`() {
BatteryLayout.forCells(2) shouldBe NARROW
}
@Test fun `3 cells wide resolves to narrow`() {
BatteryLayout.forCells(3) shouldBe NARROW
}
@Test fun `4 cells wide resolves to narrow`() {
BatteryLayout.forCells(4) shouldBe NARROW
}
@Test fun `5 cells wide resolves to wide`() {
BatteryLayout.forCells(5) shouldBe WIDE
}
@Test fun `8 cells wide resolves to wide`() {
BatteryLayout.forCells(8) shouldBe WIDE
}
@Test fun `zero cells resolves to tiny column not thrown`() {
BatteryLayout.forCells(0) shouldBe TINY_COLUMN
}
}