feat(widget): Migrate from RemoteViews to Jetpack Glance

Replace the legacy AppWidgetProvider/RemoteViews implementation with Glance AppWidget for reactive updates, proper centering, and a unified render state model.

- Add BatteryGlanceWidget with collectAsState for live device/profile/upgrade data

- Add WidgetRenderState sealed class and WidgetRenderStateMapper

- Add GlanceWidgetContent (Glance composables) and ComposeWidgetPreview (config preview)

- Fix centering by passing spacing via modifier param (no trailing padding on last item)

- Remove old XML widget layouts and RemoteViews rendering code
This commit is contained in:
darken
2026-03-01 15:56:43 +01:00
committed by Matthias Urhahn
parent 4db2edd29a
commit c103c6adc9
23 changed files with 1063 additions and 1175 deletions
+1
View File
@@ -188,6 +188,7 @@ dependencies {
addNavigation()
addCompose()
addGlance()
addNavigation3()
addSerialization()
@@ -1,32 +1,23 @@
package eu.darken.capod.screenshots
import android.content.res.Configuration
import androidx.annotation.DrawableRes
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.graphics.toArgb
import eu.darken.capod.R
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
import eu.darken.capod.main.ui.widget.WidgetRenderState
import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
@@ -234,116 +225,12 @@ internal fun HomescreenWidgetContent() {
),
contentAlignment = Alignment.Center,
) {
WidgetDualCompact()
}
}
}
@Composable
private fun WidgetDualCompact() {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp,
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
WidgetPodRow(
icon = R.drawable.device_airpods_pro2_left,
percent = 85,
charging = false,
inEar = true,
)
WidgetPodRow(
icon = R.drawable.device_airpods_pro2_right,
percent = 92,
charging = true,
inEar = false,
)
WidgetCaseRow(
icon = R.drawable.device_airpods_pro2_case,
percent = 100,
charging = false,
)
Text(
text = "My AirPods Pro",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Composable
private fun WidgetPodRow(
@DrawableRes icon: Int,
percent: Int,
charging: Boolean,
inEar: Boolean,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(vertical = 2.dp),
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Text(
text = "$percent%",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 4.dp),
)
if (charging) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
)
}
if (inEar) {
Image(
painter = painterResource(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
)
}
}
}
@Composable
private fun WidgetCaseRow(
@DrawableRes icon: Int,
percent: Int,
charging: Boolean,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(vertical = 2.dp),
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Text(
text = "$percent%",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 4.dp),
)
if (charging) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
ComposeWidgetPreview(
state = WidgetRenderState.previewDualPod(
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
),
)
}
}
+3
View File
@@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltAndroidApp
@@ -42,6 +43,8 @@ open class App : Application() {
monitorControl.startMonitor(forceStart = true)
appScope.launch { widgetManager.refreshWidgets() }
podMonitor.devicesWithProfiles()
.distinctUntilChanged()
.throttleLatest(1000)
@@ -0,0 +1,126 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.glance.GlanceId
import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.profiles.core.DeviceProfilesRepo
class BatteryGlanceWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Exact
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun podMonitor(): PodMonitor
fun upgradeRepo(): UpgradeRepo
fun widgetSettings(): WidgetSettings
fun deviceProfilesRepo(): DeviceProfilesRepo
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
val ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
// Pre-load initial values (runs once per session, suspend OK)
val initialIsPro = ep.upgradeRepo().isPro()
val initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
val cachedDevice = initialProfileId?.let { ep.podMonitor().getDeviceForProfile(it) }
provideContent {
// Composable reads — must be outside try-catch
val devices by ep.podMonitor().devices.collectAsState(initial = emptyList())
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
val widthDp = LocalSize.current.width
val state = try {
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
val theme = WidgetTheme.fromBundle(
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
)
val isPro = upgradeInfo?.isPro ?: initialIsPro
val liveDevice = devices.firstOrNull { it.meta.profile?.id == profileId }
val device = liveDevice ?: cachedDevice?.takeIf { it.meta.profile?.id == profileId }
val profileLabel = profileId?.let { pid ->
profiles.firstOrNull { it.id == pid }?.label
}
val isWide = getCellsForSize(widthDp.value.toInt()) >= 5
WidgetRenderStateMapper.map(
context = context,
device = device,
theme = theme,
isPro = isPro,
hasConfiguredProfile = profileId != null,
profileLabel = profileLabel,
isWide = isWide,
)
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
WidgetRenderState.Message(
theme = WidgetTheme.DEFAULT,
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
)
}
GlanceWidgetContent(state = state, context = context)
}
}
override suspend fun providePreview(context: Context, widgetCategory: Int) {
val previewState = WidgetRenderState.previewDualPod(
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
)
provideContent {
GlanceWidgetContent(state = previewState, context = context)
}
}
/**
* Returns number of cells needed for given size of the widget.
* https://developer.android.com/guide/practices/ui_guidelines/widget_design
*/
private fun getCellsForSize(size: Int): Int {
var n = 2
while (70 * n - 30 < size) {
++n
}
return n - 1
}
companion object {
val TAG = logTag("Widget", "Glance")
}
}
@@ -0,0 +1,373 @@
package eu.darken.capod.main.ui.widget
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import kotlin.math.roundToInt
@Composable
fun ComposeWidgetPreview(
state: WidgetRenderState,
modifier: Modifier = Modifier,
) {
when (state) {
is WidgetRenderState.DualPod -> DualPodPreview(state, modifier)
is WidgetRenderState.SinglePod -> SinglePodPreview(state, modifier)
is WidgetRenderState.Message -> MessagePreview(state, modifier)
is WidgetRenderState.Loading -> LoadingPreview(state, modifier)
}
}
@Composable
fun CheckerboardBackground(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val lightColor = Color(0xFFE8E8E8)
val darkColor = Color(0xFFD0D0D0)
Box(
modifier = modifier.clipToBounds().drawBehind {
val cellSize = 8.dp.toPx()
val cols = (size.width / cellSize).toInt() + 1
val rows = (size.height / cellSize).toInt() + 1
drawRect(lightColor)
for (row in 0 until rows) {
for (col in 0 until cols) {
if ((row + col) % 2 == 0) {
drawRect(
color = darkColor,
topLeft = Offset(col * cellSize, row * cellSize),
size = Size(cellSize, cellSize),
)
}
}
}
},
) {
content()
}
}
@Composable
private fun DualPodPreview(
state: WidgetRenderState.DualPod,
modifier: Modifier = Modifier,
) {
val bgColor = Color(state.resolvedBgColor)
val textColor = Color(state.resolvedTextColor)
val iconColor = Color(state.resolvedIconColor)
val iconTint = ColorFilter.tint(iconColor)
if (state.isWide) {
// Wide layout: left | case | right in a horizontal row
WidgetContainer(bgColor = bgColor, modifier = modifier) {
Row(
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
// Left pod
PodItemRow(
icon = state.leftIcon,
percent = state.leftPercent,
charging = state.leftCharging,
inEar = state.leftInEar,
textColor = textColor,
iconTint = iconTint,
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
WidgetContainer(bgColor = bgColor, modifier = modifier) {
PodItemRow(
icon = state.leftIcon,
percent = state.leftPercent,
charging = state.leftCharging,
inEar = state.leftInEar,
textColor = textColor,
iconTint = iconTint,
)
PodItemRow(
icon = state.rightIcon,
percent = state.rightPercent,
charging = state.rightCharging,
inEar = state.rightInEar,
textColor = textColor,
iconTint = iconTint,
)
PodItemRow(
icon = state.caseIcon,
percent = state.casePercent,
charging = state.caseCharging,
inEar = false,
textColor = textColor,
iconTint = iconTint,
)
DeviceLabel(
label = state.deviceLabel,
visible = state.theme.showDeviceLabel,
textColor = textColor,
)
}
}
}
@Composable
private fun SinglePodPreview(
state: WidgetRenderState.SinglePod,
modifier: Modifier = Modifier,
) {
val bgColor = Color(state.resolvedBgColor)
val textColor = Color(state.resolvedTextColor)
val iconTint = ColorFilter.tint(Color(state.resolvedIconColor))
WidgetContainer(bgColor = bgColor, modifier = modifier) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(state.batteryIcon),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(state.percent),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 8.dp),
)
if (state.charging) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
if (state.worn) {
Image(
painter = painterResource(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
}
DeviceLabel(
label = state.deviceLabel,
visible = state.theme.showDeviceLabel,
textColor = textColor,
)
}
}
@Composable
private fun MessagePreview(
state: WidgetRenderState.Message,
modifier: Modifier = Modifier,
) {
val bgColor = Color(state.resolvedBgColor)
val textColor = Color(state.resolvedTextColor)
WidgetContainer(bgColor = bgColor, modifier = modifier) {
Text(
text = state.primaryText,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
if (state.secondaryText != null) {
Text(
text = state.secondaryText,
fontSize = 12.sp,
color = textColor,
textAlign = TextAlign.Center,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
private fun LoadingPreview(
state: WidgetRenderState.Loading,
modifier: Modifier = Modifier,
) {
val bgColor = Color(state.resolvedBgColor)
val textColor = Color(state.resolvedTextColor)
WidgetContainer(bgColor = bgColor, modifier = modifier) {
Text(
text = "",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun WidgetContainer(
bgColor: Color,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Column(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(bgColor)
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
content()
}
}
@Composable
private fun PodItemRow(
icon: Int,
percent: Float?,
charging: Boolean,
inEar: Boolean,
textColor: Color,
iconTint: ColorFilter,
iconSize: Int = 20,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(percent),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 4.dp),
)
if (charging) {
Image(
painter = painterResource(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
if (inEar) {
Image(
painter = painterResource(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = Modifier.size(20.dp),
colorFilter = iconTint,
)
}
}
}
@Composable
private fun DeviceLabel(
label: String?,
visible: Boolean,
textColor: Color,
modifier: Modifier = Modifier,
) {
if (visible && label != null) {
Text(
text = label,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center,
modifier = modifier,
)
}
}
private fun formatPercent(percent: Float?): String {
return percent?.let { "${(it * 100).roundToInt()}%" } ?: ""
}
@Preview2
@Composable
private fun PreviewDualCompact() = PreviewWrapper {
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod())
}
@Preview2
@Composable
private fun PreviewDualWide() = PreviewWrapper {
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(isWide = true))
}
@@ -0,0 +1,271 @@
package eu.darken.capod.main.ui.widget
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.background
import androidx.glance.unit.ColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import eu.darken.capod.R
import eu.darken.capod.main.ui.MainActivity
import kotlin.math.roundToInt
@Composable
fun GlanceWidgetContent(
state: WidgetRenderState,
context: android.content.Context,
) {
val openApp = actionStartActivity(
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
)
when (state) {
is WidgetRenderState.DualPod -> GlanceDualPod(state, GlanceModifier.clickable(openApp))
is WidgetRenderState.SinglePod -> GlanceSinglePod(state, GlanceModifier.clickable(openApp))
is WidgetRenderState.Message -> GlanceMessage(state, GlanceModifier.clickable(openApp))
is WidgetRenderState.Loading -> GlanceLoading(state, GlanceModifier.clickable(openApp))
}
}
@Composable
private fun GlanceDualPod(
state: WidgetRenderState.DualPod,
clickModifier: GlanceModifier,
) {
val textStyle = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
)
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
if (state.isWide) {
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
Row(
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
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)
}
} else {
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
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)
}
}
}
@Composable
private fun GlanceSinglePod(
state: WidgetRenderState.SinglePod,
clickModifier: GlanceModifier,
) {
val textStyle = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
)
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
Row(
modifier = GlanceModifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
provider = ImageProvider(state.batteryIcon),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(state.percent),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 8.dp),
)
if (state.charging) {
Image(
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)
}
}
@Composable
private fun GlanceMessage(
state: WidgetRenderState.Message,
clickModifier: GlanceModifier,
) {
val textStyle = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
Text(
text = state.primaryText,
style = textStyle,
modifier = GlanceModifier.fillMaxWidth(),
)
if (state.secondaryText != null) {
Text(
text = state.secondaryText,
style = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier.fillMaxWidth(),
maxLines = 4,
)
}
}
}
@Composable
private fun GlanceLoading(
state: WidgetRenderState.Loading,
clickModifier: GlanceModifier,
) {
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
Text(
text = "",
style = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier.fillMaxWidth(),
)
}
}
@Composable
private fun GlanceWidgetRoot(
bgColor: Int,
clickModifier: GlanceModifier,
content: @Composable () -> Unit,
) {
Column(
modifier = clickModifier
.fillMaxSize()
.background(fixedColor(bgColor))
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
content()
}
}
@Composable
private fun GlancePodItem(
icon: Int,
percent: Float?,
charging: Boolean,
inEar: Boolean,
textStyle: TextStyle,
iconTint: ColorFilter,
iconSize: Int = 20,
modifier: GlanceModifier = GlanceModifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size(iconSize.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
if (charging) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_power_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
if (inEar) {
Image(
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
contentDescription = null,
modifier = GlanceModifier.size(20.dp),
colorFilter = iconTint,
)
}
}
}
@Composable
private fun GlanceDeviceLabel(
label: String?,
visible: Boolean,
textColor: Int,
) {
if (visible && label != null) {
Text(
text = label,
style = TextStyle(
color = fixedColor(textColor),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
)
}
}
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
private fun formatGlancePercent(percent: Float?): String {
return percent?.let { "${(it * 100).roundToInt()}%" } ?: ""
}
@@ -14,6 +14,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.core.view.WindowCompat
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.compose.waitForState
@@ -23,6 +25,7 @@ import eu.darken.capod.common.theming.CapodTheme
import eu.darken.capod.common.uix.Activity2
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@@ -99,15 +102,12 @@ class WidgetConfigurationActivity : Activity2() {
val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
setResult(RESULT_OK, resultValue)
val appWidgetManager = AppWidgetManager.getInstance(appContext)
WidgetProvider.updateWidget(
context = appContext,
appWidgetManager = appWidgetManager,
widgetId = widgetId
)
finish()
lifecycleScope.launch {
val manager = GlanceAppWidgetManager(appContext)
val glanceId = manager.getGlanceIdBy(widgetId)
BatteryGlanceWidget().update(appContext, glanceId)
finish()
}
}
companion object {
@@ -1,13 +1,5 @@
package eu.darken.capod.main.ui.widget
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.Shader
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
@@ -78,15 +70,10 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.isVisible
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@@ -126,6 +113,7 @@ fun WidgetConfigurationScreen(
Text(
text = stringResource(R.string.widget_config_screen_title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier
.fillMaxWidth()
.padding(start = screenHPad, end = screenHPad, bottom = 16.dp),
@@ -196,7 +184,7 @@ fun WidgetConfigurationScreen(
Spacer(modifier = Modifier.height(12.dp))
// Live preview
WidgetPreview(
WidgetConfigPreview(
theme = state.theme,
deviceLabel = state.profiles.firstOrNull { it.id == state.selectedProfile }?.label,
)
@@ -445,41 +433,21 @@ private fun ProfileSelectionItem(
}
@Composable
private fun WidgetPreview(
private fun WidgetConfigPreview(
theme: WidgetTheme,
deviceLabel: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val density = LocalDensity.current
val hasTransparency = theme.backgroundColor != null && theme.backgroundAlpha < 255
val checkerboardDrawable = remember(density) {
val cellSize = (8 * context.resources.displayMetrics.density).toInt()
val bitmap = createBitmap(cellSize * 2, cellSize * 2)
val canvas = Canvas(bitmap)
val paint = Paint()
paint.color = 0xFFE8E8E8.toInt()
canvas.drawRect(0f, 0f, (cellSize * 2).toFloat(), (cellSize * 2).toFloat(), paint)
paint.color = 0xFFD0D0D0.toInt()
canvas.drawRect(0f, 0f, cellSize.toFloat(), cellSize.toFloat(), paint)
canvas.drawRect(
cellSize.toFloat(), cellSize.toFloat(),
(cellSize * 2).toFloat(), (cellSize * 2).toFloat(), paint,
)
bitmap.toDrawable(context.resources).apply {
tileModeX = Shader.TileMode.REPEAT
tileModeY = Shader.TileMode.REPEAT
}
}
val resolvedBgColor = remember(context) {
resolveThemeColor(context, android.R.attr.colorBackground)
}
val resolvedTextColor = remember(context) {
resolveThemeColor(context, android.R.attr.textColorPrimary)
}
val resolvedAccentColor = remember(context) {
resolveThemeColor(context, android.R.attr.colorAccent)
val previewState = remember(theme, deviceLabel) {
WidgetRenderState.previewDualPod(
theme = theme,
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
).copy(deviceLabel = deviceLabel)
}
Surface(
@@ -488,95 +456,24 @@ private fun WidgetPreview(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
tonalElevation = 2.dp,
) {
AndroidView(
factory = { ctx ->
val container = android.widget.FrameLayout(ctx)
// Outer container for checkerboard
val outerPadding = (24 * ctx.resources.displayMetrics.density).toInt()
container.setPadding(outerPadding, outerPadding, outerPadding, outerPadding)
// Clip wrapper — rounded background + clipToOutline so the inner content is clipped
val clipWrapper = android.widget.FrameLayout(ctx).apply {
setBackgroundResource(R.drawable.widget_preview_bg)
clipToOutline = true
}
// Inner preview content
LayoutInflater.from(ctx).inflate(R.layout.widget_config_preview, clipWrapper, true)
container.addView(
clipWrapper, android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
android.view.Gravity.CENTER,
)
)
container
},
update = { container ->
val hasTransparency = theme.backgroundColor != null && theme.backgroundAlpha < 255
container.background = if (hasTransparency) {
checkerboardDrawable
} else {
androidx.appcompat.content.res.AppCompatResources.getDrawable(
context, R.drawable.widget_preview_checkerboard
Box(
modifier = Modifier.fillMaxWidth().padding(24.dp),
contentAlignment = Alignment.Center,
) {
if (hasTransparency) {
CheckerboardBackground {
ComposeWidgetPreview(
state = previewState,
modifier = Modifier.padding(6.dp),
)
}
val clipWrapper = container.getChildAt(0) ?: return@AndroidView
val widgetRoot = clipWrapper.findViewById<View>(R.id.preview_widget_root) ?: return@AndroidView
// Background color applied to the inner view — clipWrapper's outline clips the corners
val bgColor = theme.backgroundColor
if (bgColor != null) {
widgetRoot.setBackgroundColor(WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha))
} else {
widgetRoot.setBackgroundColor(resolvedBgColor)
}
// Foreground colors
val fgColor = theme.foregroundColor
val textColor = fgColor ?: resolvedTextColor
val iconColor = fgColor ?: resolvedAccentColor
val textIds = intArrayOf(
R.id.preview_left_label, R.id.preview_right_label,
R.id.preview_case_label, R.id.preview_device_label,
)
val iconIds = intArrayOf(
R.id.preview_left_icon, R.id.preview_right_icon, R.id.preview_case_icon,
)
for (id in textIds) {
clipWrapper.findViewById<TextView>(id)?.setTextColor(textColor)
}
for (id in iconIds) {
clipWrapper.findViewById<ImageView>(id)?.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN)
}
// Device label
val labelView = clipWrapper.findViewById<TextView>(R.id.preview_device_label)
labelView?.isVisible = theme.showDeviceLabel
labelView?.text = deviceLabel ?: ""
},
modifier = Modifier.fillMaxWidth(),
)
} else {
ComposeWidgetPreview(state = previewState)
}
}
}
}
private fun resolveThemeColor(context: android.content.Context, attr: Int): Int {
val wrapper = androidx.appcompat.view.ContextThemeWrapper(
context,
com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight,
)
val typedArray = wrapper.theme.obtainStyledAttributes(intArrayOf(attr))
val color = typedArray.getColor(0, android.graphics.Color.BLACK)
typedArray.recycle()
return color
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun PresetChips(
@@ -638,8 +535,8 @@ private fun PresetChips(
FilterChip(
selected = isCustomMode,
onClick = {
val defaultBg = resolveThemeColor(context, android.R.attr.colorBackground)
val defaultFg = resolveThemeColor(context, android.R.attr.textColorPrimary)
val defaultBg = WidgetRenderStateMapper.resolveThemeColor(context, android.R.attr.colorBackground)
val defaultFg = WidgetRenderStateMapper.resolveThemeColor(context, android.R.attr.textColorPrimary)
onEnterCustomMode(defaultBg, defaultFg)
},
label = { Text(stringResource(R.string.widget_config_custom_label)) },
@@ -1,11 +1,8 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.updateAll
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import javax.inject.Inject
@@ -17,25 +14,12 @@ class WidgetManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val widgetManager by lazy { AppWidgetManager.getInstance(context) }
private val currentWidgetIds: IntArray
get() = widgetManager.getAppWidgetIds(ComponentName(context, PROVIDER_CLASS))
suspend fun refreshWidgets() {
log(TAG) { "refreshWidgets()" }
log(TAG, VERBOSE) { "Notifying these widget IDs: ${currentWidgetIds.toList()}" }
val intent = Intent(context, PROVIDER_CLASS).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, currentWidgetIds)
}
context.sendBroadcast(intent)
BatteryGlanceWidget().updateAll(context)
}
companion object {
val PROVIDER_CLASS = WidgetProvider::class.java
val TAG = logTag("Widget", "Manager")
}
}
}
@@ -1,474 +1,29 @@
package eu.darken.capod.main.ui.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.RemoteViews
import androidx.annotation.LayoutRes
import androidx.appcompat.view.ContextThemeWrapper
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.asLog
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import dagger.hilt.android.EntryPointAccessors
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.monitor.core.PodDeviceCache
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import finish2
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.time.Duration
import javax.inject.Inject
class WidgetProvider : GlanceAppWidgetReceiver() {
@AndroidEntryPoint
class WidgetProvider : AppWidgetProvider() {
@Inject lateinit var podMonitor: PodMonitor
@Inject lateinit var podDeviceCache: PodDeviceCache
@Inject lateinit var podFactory: PodFactory
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject lateinit var widgetSettings: WidgetSettings
@Inject lateinit var deviceProfilesRepo: DeviceProfilesRepo
@AppScope @Inject lateinit var appScope: CoroutineScope
private fun executeAsync(
tag: String,
timeout: Duration = Duration.ofSeconds(7),
block: suspend () -> Unit
) {
val start = System.currentTimeMillis()
val asyncBarrier = goAsync()
log(TAG, VERBOSE) { "executeAsync($tag) starting asyncBarrier=$asyncBarrier " }
appScope.launch {
try {
withTimeout(timeout.toMillis()) { block() }
} catch (e: Exception) {
log(TAG, ERROR) { "executeAsync($tag) failed: ${e.asLog()}" }
} finally {
asyncBarrier.finish2()
val stop = System.currentTimeMillis()
log(TAG, VERBOSE) { "executeAsync($tag) DONE (${stop - start}ms) " }
}
}
log(TAG, VERBOSE) { "executeAsync($block) leaving" }
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
log(TAG) { "onUpdate(appWidgetIds=${appWidgetIds.toList()})" }
executeAsync("onUpdate") {
appWidgetIds.forEach { appWidgetId ->
updateWidget(context, appWidgetManager, appWidgetId, null)
}
}
}
override fun onAppWidgetOptionsChanged(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
newOptions: Bundle?
) {
log(TAG) { "onAppWidgetOptionsChanged(appWidgetId=$appWidgetId, newOptions=$newOptions)" }
executeAsync("onAppWidgetOptionsChanged") {
updateWidget(context, appWidgetManager, appWidgetId, newOptions)
}
}
override val glanceAppWidget: GlanceAppWidget = BatteryGlanceWidget()
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
log(TAG) { "onDeleted(appWidgetIds=${appWidgetIds.toList()})" }
executeAsync("onDeleted") {
appWidgetIds.forEach { widgetId ->
widgetSettings.removeWidget(widgetId)
}
super.onDeleted(context, appWidgetIds)
val ep = EntryPointAccessors.fromApplication(
context.applicationContext,
BatteryGlanceWidget.WidgetEntryPoint::class.java,
)
appWidgetIds.forEach { widgetId ->
ep.widgetSettings().removeWidget(widgetId)
}
}
/**
* Returns number of cells needed for given size of the widget.
*
* The value is determined in accordance with official guidelines
* for designing widgets, see:
* https://developer.android.com/guide/practices/ui_guidelines/widget_design
*
* Thanks to Jakub S. on Stackoverflow for this solution:
* https://stackoverflow.com/a/37522648/10866268
*
* @param size Widget size in dp.
* @return Size in number of cells.
*/
private fun getCellsForSize(size: Int): Int {
var n = 2
while (70 * n - 30 < size) {
++n
}
return n - 1
}
private suspend fun updateWidget(
context: Context,
widgetManager: AppWidgetManager,
widgetId: Int,
options: Bundle?
) {
val profileId: ProfileId? = widgetSettings.getWidgetProfile(widgetId)
log(TAG) { "updateWidget(widgetId=$widgetId, profileId=$profileId options=$options)" }
val device: PodDevice? = profileId?.let { podMonitor.getDeviceForProfile(it) }
val profileLabel: String? = profileId?.let { id ->
deviceProfilesRepo.profiles.first().firstOrNull { it.id == id }?.label
}
val theme = WidgetTheme.fromBundle(widgetManager.getAppWidgetOptions(widgetId))
log(TAG, VERBOSE) { "updateWidget: theme=$theme" }
val layout = when {
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context, widgetId, theme)
device is DualPodDevice -> {
val minWidth = widgetManager.getAppWidgetOptions(widgetId)
.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
val columns = getCellsForSize(minWidth)
/* Enable wide widgets only when we are provided with 5 or
* more columns of space.
* Although the minimum size is only 2x1, the safeguards are
* added if these restrictions will ever be loosened in the future.
*/
val layout = when (columns) {
in 1..4 -> R.layout.widget_pod_dual_compact_layout
else -> R.layout.widget_pod_dual_wide_layout
}
createDualPodLayout(context, device, layout, widgetId, theme, profileLabel)
}
device is SinglePodDevice -> createSinglePodLayout(context, device, widgetId, theme, profileLabel)
device is PodDevice -> createUnknownPodLayout(context, device, widgetId, theme)
else -> createNoDeviceLayout(context, profileId != null, widgetId, theme)
}
widgetManager.updateAppWidget(widgetId, layout)
}
private fun applyThemeColors(
context: Context,
views: RemoteViews,
theme: WidgetTheme,
textViewIds: List<Int>,
iconViewIds: List<Int>,
hasDeviceLabel: Boolean,
) {
// Always explicitly set background color to ensure previous custom colors are overwritten
val bgColor = theme.backgroundColor
if (bgColor != null) {
val colorWithAlpha = WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha)
views.setInt(R.id.widget_root, "setBackgroundColor", colorWithAlpha)
} else {
// Reset to theme default — resolve ?android:attr/colorBackground
val defaultBg = resolveThemeColor(context, android.R.attr.colorBackground)
views.setInt(R.id.widget_root, "setBackgroundColor", defaultBg)
}
// Always explicitly set text/icon colors
val fgColor = theme.foregroundColor
if (fgColor != null) {
for (textViewId in textViewIds) {
views.setTextColor(textViewId, fgColor)
}
for (iconViewId in iconViewIds) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
views.setColorStateList(iconViewId, "setImageTintList", ColorStateList.valueOf(fgColor))
} else {
views.setInt(iconViewId, "setColorFilter", fgColor)
}
}
} else {
// Reset to theme defaults
val defaultTextColor = resolveThemeColor(context, android.R.attr.textColorPrimary)
val defaultIconColor = resolveThemeColor(context, android.R.attr.colorAccent)
for (textViewId in textViewIds) {
views.setTextColor(textViewId, defaultTextColor)
}
for (iconViewId in iconViewIds) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
views.setColorStateList(iconViewId, "setImageTintList", ColorStateList.valueOf(defaultIconColor))
} else {
views.setInt(iconViewId, "setColorFilter", defaultIconColor)
}
}
}
if (hasDeviceLabel) {
views.setViewVisibility(
R.id.headphones_label,
if (theme.showDeviceLabel) View.VISIBLE else View.GONE
)
}
}
private fun resolveThemeColor(context: Context, attr: Int): Int {
val themedContext = ContextThemeWrapper(context, com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight)
val typedArray = themedContext.theme.obtainStyledAttributes(intArrayOf(attr))
val color = typedArray.getColor(0, android.graphics.Color.BLACK)
typedArray.recycle()
return color
}
private suspend fun createUpgradeRequiredLayout(
context: Context,
widgetId: Int,
theme: WidgetTheme,
) = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createUpgradeRequiredLayout(context=$context)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
widgetId,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.primary, context.getString(R.string.upgrade_capod_label))
setTextViewText(R.id.secondary, context.getString(R.string.upgrade_capod_description))
setViewVisibility(R.id.secondary, View.VISIBLE)
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(R.id.primary, R.id.secondary),
iconViewIds = emptyList(),
hasDeviceLabel = false,
)
}
private fun createUnknownPodLayout(
context: Context,
podDevice: PodDevice,
widgetId: Int,
theme: WidgetTheme,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createUnknownPodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
widgetId,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.primary, context.getString(R.string.pods_unknown_label))
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(R.id.primary),
iconViewIds = emptyList(),
hasDeviceLabel = false,
)
}
private fun createNoDeviceLayout(
context: Context,
hasConfiguredProfile: Boolean = false,
widgetId: Int,
theme: WidgetTheme,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context, hasConfiguredProfile=$hasConfiguredProfile)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
widgetId,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
val messageRes = if (hasConfiguredProfile) {
R.string.widget_no_data_label
} else {
R.string.overview_nomaindevice_label
}
setTextViewText(R.id.primary, context.getString(messageRes))
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(R.id.primary),
iconViewIds = emptyList(),
hasDeviceLabel = false,
)
}
private fun createDualPodLayout(
context: Context,
podDevice: DualPodDevice,
@LayoutRes layout: Int,
widgetId: Int,
theme: WidgetTheme,
profileLabel: String?,
): RemoteViews = RemoteViews(context.packageName, layout).apply {
log(TAG, VERBOSE) { "createDualPodLayout(context=$context, podDevice=$podDevice), layout=${layout}" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
widgetId,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.headphones_label, profileLabel ?: podDevice.getLabel(context))
// Left
val leftPercent = podDevice.batteryLeftPodPercent
setImageViewResource(R.id.pod_left_icon, podDevice.leftPodIcon)
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
setViewVisibility(
R.id.pod_left_charging,
if (podDevice is HasChargeDetectionDual && podDevice.isLeftPodCharging) View.VISIBLE else View.GONE
)
setViewVisibility(
R.id.pod_left_ear,
if (podDevice is HasEarDetectionDual && podDevice.isLeftPodInEar) View.VISIBLE else View.GONE
)
// Case
(podDevice as? HasCase)?.let { setImageViewResource(R.id.pod_case_icon, it.caseIcon) }
val casePercent = (podDevice as? HasCase)?.batteryCasePercent
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
setViewVisibility(
R.id.pod_case_charging,
if (podDevice is HasCase && podDevice.isCaseCharging) View.VISIBLE else View.GONE
)
// Right
val rightPercent = podDevice.batteryRightPodPercent
setImageViewResource(R.id.pod_right_icon, podDevice.rightPodIcon)
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
setViewVisibility(
R.id.pod_right_charging,
if (podDevice is HasChargeDetectionDual && podDevice.isRightPodCharging) View.VISIBLE else View.GONE
)
setViewVisibility(
R.id.pod_right_ear,
if (podDevice is HasEarDetectionDual && podDevice.isRightPodInEar) View.VISIBLE else View.GONE
)
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(
R.id.headphones_label,
R.id.pod_left_label,
R.id.pod_right_label,
R.id.pod_case_label,
),
iconViewIds = listOf(
R.id.pod_left_icon,
R.id.pod_left_charging,
R.id.pod_left_ear,
R.id.pod_case_icon,
R.id.pod_case_charging,
R.id.pod_right_icon,
R.id.pod_right_charging,
R.id.pod_right_ear,
),
hasDeviceLabel = true,
)
}
private fun createSinglePodLayout(
context: Context,
podDevice: SinglePodDevice,
widgetId: Int,
theme: WidgetTheme,
profileLabel: String?,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_single_layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
widgetId,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
val headsetPercent = podDevice.batteryHeadsetPercent
setTextViewText(R.id.headphones_label, profileLabel ?: podDevice.getLabel(context))
setImageViewResource(R.id.headphones_icon, podDevice.iconRes)
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
setViewVisibility(
R.id.headphones_worn,
if (podDevice is HasEarDetection && podDevice.isBeingWorn) View.VISIBLE else View.GONE
)
setViewVisibility(
R.id.headphones_charging,
if (podDevice is HasChargeDetectionDual && podDevice.isHeadsetBeingCharged) View.VISIBLE else View.GONE
)
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(
R.id.headphones_label,
R.id.headphones_battery_label,
),
iconViewIds = listOf(
R.id.headphones_icon,
R.id.headphones_battery_icon,
R.id.headphones_charging,
R.id.headphones_worn,
),
hasDeviceLabel = true,
)
}
companion object {
val TAG = logTag("Widget", "Provider")
fun updateWidget(context: Context, appWidgetManager: AppWidgetManager, widgetId: Int) {
val intent = Intent(context, WidgetProvider::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId))
}
context.sendBroadcast(intent)
}
}
}
@@ -0,0 +1,89 @@
package eu.darken.capod.main.ui.widget
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import eu.darken.capod.R
sealed class WidgetRenderState {
abstract val theme: WidgetTheme
@get:ColorInt abstract val resolvedBgColor: Int
@get:ColorInt abstract val resolvedTextColor: Int
@get:ColorInt abstract val resolvedIconColor: Int
data class DualPod(
override val theme: WidgetTheme,
@ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int,
val isWide: Boolean,
val deviceLabel: String?,
@DrawableRes val leftIcon: Int,
val leftPercent: Float?,
val leftCharging: Boolean,
val leftInEar: Boolean,
@DrawableRes val rightIcon: Int,
val rightPercent: Float?,
val rightCharging: Boolean,
val rightInEar: Boolean,
@DrawableRes val caseIcon: Int,
val casePercent: Float?,
val caseCharging: Boolean,
) : WidgetRenderState()
data class SinglePod(
override val theme: WidgetTheme,
@ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int,
val deviceLabel: String?,
@DrawableRes val headsetIcon: Int,
val percent: Float?,
@DrawableRes val batteryIcon: Int,
val charging: Boolean,
val worn: Boolean,
) : WidgetRenderState()
data class Message(
override val theme: WidgetTheme,
@ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int,
val primaryText: String,
val secondaryText: String? = null,
) : WidgetRenderState()
data class Loading(
override val theme: WidgetTheme,
@ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int,
) : WidgetRenderState()
companion object {
fun previewDualPod(
theme: WidgetTheme = WidgetTheme.DEFAULT,
@ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
@ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
@ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
isWide: Boolean = false,
): DualPod = DualPod(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
isWide = isWide,
deviceLabel = "My AirPods Pro",
leftIcon = R.drawable.device_airpods_pro2_left,
leftPercent = 0.85f,
leftCharging = false,
leftInEar = true,
rightIcon = R.drawable.device_airpods_pro2_right,
rightPercent = 0.92f,
rightCharging = true,
rightInEar = false,
caseIcon = R.drawable.device_airpods_pro2_case,
casePercent = 1.0f,
caseCharging = false,
)
}
}
@@ -0,0 +1,130 @@
package eu.darken.capod.main.ui.widget
import android.content.Context
import androidx.annotation.ColorInt
import androidx.appcompat.view.ContextThemeWrapper
import eu.darken.capod.R
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.getBatteryDrawable
object WidgetRenderStateMapper {
fun map(
context: Context,
device: PodDevice?,
theme: WidgetTheme,
isPro: Boolean,
hasConfiguredProfile: Boolean,
profileLabel: String?,
isWide: Boolean = false,
): WidgetRenderState {
val bgColor = resolvedBgColor(context, theme)
val textColor = resolvedTextColor(context, theme)
val iconColor = resolvedIconColor(context, theme)
return when {
!isPro -> WidgetRenderState.Message(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
primaryText = context.getString(R.string.upgrade_capod_label),
secondaryText = context.getString(R.string.upgrade_capod_description),
)
device is DualPodDevice -> WidgetRenderState.DualPod(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
isWide = isWide,
deviceLabel = profileLabel ?: device.getLabel(context),
leftIcon = device.leftPodIcon,
leftPercent = device.batteryLeftPodPercent,
leftCharging = device is HasChargeDetectionDual && device.isLeftPodCharging,
leftInEar = device is HasEarDetectionDual && device.isLeftPodInEar,
rightIcon = device.rightPodIcon,
rightPercent = device.batteryRightPodPercent,
rightCharging = device is HasChargeDetectionDual && device.isRightPodCharging,
rightInEar = device is HasEarDetectionDual && device.isRightPodInEar,
caseIcon = (device as? HasCase)?.caseIcon ?: R.drawable.device_airpods_gen1_case,
casePercent = (device as? HasCase)?.batteryCasePercent,
caseCharging = device is HasCase && device.isCaseCharging,
)
device is SinglePodDevice -> WidgetRenderState.SinglePod(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
deviceLabel = profileLabel ?: device.getLabel(context),
headsetIcon = device.iconRes,
percent = device.batteryHeadsetPercent,
batteryIcon = getBatteryDrawable(device.batteryHeadsetPercent),
charging = device is HasChargeDetectionDual && device.isHeadsetBeingCharged,
worn = device is HasEarDetection && device.isBeingWorn,
)
device is PodDevice -> WidgetRenderState.Message(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
primaryText = context.getString(R.string.pods_unknown_label),
)
else -> {
val messageRes = if (hasConfiguredProfile) {
R.string.widget_no_data_label
} else {
R.string.overview_nomaindevice_label
}
WidgetRenderState.Message(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
primaryText = context.getString(messageRes),
)
}
}
}
@ColorInt
fun resolvedBgColor(context: Context, theme: WidgetTheme): Int {
val bgColor = theme.backgroundColor
return if (bgColor != null) {
WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha)
} else {
resolveThemeColor(context, android.R.attr.colorBackground)
}
}
@ColorInt
fun resolvedTextColor(context: Context, theme: WidgetTheme): Int {
return theme.foregroundColor ?: resolveThemeColor(context, android.R.attr.textColorPrimary)
}
@ColorInt
fun resolvedIconColor(context: Context, theme: WidgetTheme): Int {
return theme.foregroundColor ?: resolveThemeColor(context, android.R.attr.colorAccent)
}
@ColorInt
fun resolveThemeColor(context: Context, attr: Int): Int {
val themedContext = ContextThemeWrapper(
context,
com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight,
)
val typedArray = themedContext.theme.obtainStyledAttributes(intArrayOf(attr))
val color = typedArray.getColor(0, android.graphics.Color.BLACK)
typedArray.recycle()
return color
}
}
@@ -67,7 +67,7 @@ data class WidgetTheme(
}
fun matchPreset(theme: WidgetTheme): Preset? = Preset.entries.firstOrNull { preset ->
preset.presetBg == theme.backgroundColor && preset.presetFg == theme.foregroundColor && theme.backgroundAlpha == 255
preset.presetBg == theme.backgroundColor && preset.presetFg == theme.foregroundColor
}
}
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="@android:color/transparent" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="?colorSurfaceVariant" />
</shape>
@@ -1,100 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/preview_widget_root"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="160dp"
android:gravity="center"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/preview_left_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/device_airpods_gen1_left"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/preview_left_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="4dp"
android:text="80%"
tools:ignore="HardcodedText" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/preview_right_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/device_airpods_gen1_right"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/preview_right_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="4dp"
android:text="95%"
tools:ignore="HardcodedText" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/preview_case_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/device_airpods_gen1_case"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/preview_case_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="4dp"
android:text="100%"
tools:ignore="HardcodedText" />
</LinearLayout>
<TextView
android:id="@+id/preview_device_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
tools:text="AirPods Pro"
android:textColor="?android:attr/textColorPrimary"
android:textSize="12sp"
android:textStyle="bold"
tools:ignore="HardcodedText" />
</LinearLayout>
@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root"
style="@style/PodWidget.Container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/primary"
style="@style/PodWidget.TextPrimary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:text="@string/pods_unknown_label"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/secondary"
style="@style/PodWidget.TextSecondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:ellipsize="end"
android:gravity="center"
android:maxLines="4"
android:textSize="12sp"
android:visibility="gone"
tools:text="@string/pods_unknown_label"
tools:visibility="visible" />
</LinearLayout>
@@ -1,113 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/PodWidget.Container"
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_left_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/device_airpods_pro2_left"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/pod_left_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="4dp"
tools:text="100%" />
<ImageView
android:id="@+id/pod_left_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/pod_left_ear"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_hearing_24"
tools:ignore="ContentDescription" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_right_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/device_airpods_pro2_right"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/pod_right_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="4dp"
tools:text="100%" />
<ImageView
android:id="@+id/pod_right_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/pod_right_ear"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_hearing_24"
tools:ignore="ContentDescription" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_case_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/device_airpods_gen1_case"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/pod_case_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="4dp"
tools:text="100%" />
<ImageView
android:id="@+id/pod_case_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24" />
</LinearLayout>
<TextView
android:id="@+id/headphones_label"
style="@style/PodWidget.TextPrimary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textSize="12sp"
android:gravity="center"
android:textStyle="bold"
tools:text="AirPods Max" />
</LinearLayout>
@@ -1,130 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/PodWidget.Container"
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginEnd="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_left_icon"
style="@style/PodInfoItemIcon.Notification.Large"
android:src="@drawable/device_airpods_pro2_left"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/pod_left_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="4dp"
tools:text="100%" />
<ImageView
android:id="@+id/pod_left_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/pod_left_ear"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_hearing_24"
tools:ignore="ContentDescription" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginEnd="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_case_icon"
style="@style/PodInfoItemIcon.Notification.Large"
android:src="@drawable/device_airpods_gen1_case"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/pod_case_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="4dp"
tools:text="100%" />
<ImageView
android:id="@+id/pod_case_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_right_icon"
style="@style/PodInfoItemIcon.Notification.Large"
android:src="@drawable/device_airpods_pro2_right"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/pod_right_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="4dp"
tools:text="100%" />
<ImageView
android:id="@+id/pod_right_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/pod_right_ear"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_hearing_24"
tools:ignore="ContentDescription" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/headphones_label"
style="@style/PodWidget.TextPrimary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="4dp"
android:layout_marginBottom="8dp"
android:gravity="center"
android:textSize="12sp"
android:textStyle="bold"
tools:text="AirPods Max" />
</LinearLayout>
@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/PodWidget.Container"
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/headphones_battery_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_battery_unknown_24" />
<TextView
android:id="@+id/headphones_battery_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="8dp"
tools:text="100%" />
<ImageView
android:id="@+id/headphones_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/headphones_worn"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_hearing_24"
tools:ignore="ContentDescription" />
</LinearLayout>
<TextView
android:id="@+id/headphones_label"
style="@style/PodWidget.TextPrimary"
android:layout_width="wrap_content"
android:textStyle="bold"
android:gravity="center"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:text="AirPods Max" />
</LinearLayout>
+1
View File
@@ -123,6 +123,7 @@
<string name="common_feature_requires_pro_msg">This feature requires CAPod Pro.</string>
<string name="widget_no_data_label">No data</string>
<string name="widget_error_loading_label">Error loading widget</string>
<string name="widget_config_appearance_label">Appearance</string>
<string name="widget_config_preset_label">Presets</string>
<string name="widget_config_background_color_label">Background color</string>
+5
View File
@@ -130,6 +130,11 @@ fun DependencyHandlerScope.addSerialization() {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.Serialization.core}")
}
fun DependencyHandlerScope.addGlance() {
implementation("androidx.glance:glance-appwidget:${Versions.Glance.core}")
implementation("androidx.glance:glance-material3:${Versions.Glance.core}")
}
fun DependencyHandlerScope.addTesting() {
testImplementation("junit:junit:4.13.2")
+4
View File
@@ -22,6 +22,10 @@ object Versions {
const val core = "1.0.0"
}
object Glance {
const val core = "1.2.0-rc01"
}
object Serialization {
const val core = "1.9.0"
}