From c103c6adc9b9f026c5885eba630926f3812246c9 Mon Sep 17 00:00:00 2001 From: darken Date: Sun, 1 Mar 2026 15:27:19 +0100 Subject: [PATCH] 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 --- app/build.gradle.kts | 1 + .../capod/screenshots/ScreenshotContent.kt | 131 +---- app/src/main/java/eu/darken/capod/App.kt | 3 + .../main/ui/widget/BatteryGlanceWidget.kt | 126 +++++ .../main/ui/widget/ComposeWidgetPreview.kt | 373 ++++++++++++++ .../main/ui/widget/GlanceWidgetContent.kt | 271 ++++++++++ .../ui/widget/WidgetConfigurationActivity.kt | 18 +- .../ui/widget/WidgetConfigurationScreen.kt | 155 +----- .../capod/main/ui/widget/WidgetManager.kt | 22 +- .../capod/main/ui/widget/WidgetProvider.kt | 469 +----------------- .../capod/main/ui/widget/WidgetRenderState.kt | 89 ++++ .../main/ui/widget/WidgetRenderStateMapper.kt | 130 +++++ .../capod/main/ui/widget/WidgetTheme.kt | 2 +- .../main/res/drawable/widget_preview_bg.xml | 6 - .../drawable/widget_preview_checkerboard.xml | 6 - .../main/res/layout/widget_config_preview.xml | 100 ---- .../main/res/layout/widget_message_layout.xml | 34 -- .../layout/widget_pod_dual_compact_layout.xml | 113 ----- .../layout/widget_pod_dual_wide_layout.xml | 130 ----- .../res/layout/widget_pod_single_layout.xml | 49 -- app/src/main/res/values/strings.xml | 1 + buildSrc/src/main/java/Dependencies.kt | 5 + buildSrc/src/main/java/Versions.kt | 4 + 23 files changed, 1063 insertions(+), 1175 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/widget/ComposeWidgetPreview.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/widget/GlanceWidgetContent.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderState.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt delete mode 100644 app/src/main/res/drawable/widget_preview_bg.xml delete mode 100644 app/src/main/res/drawable/widget_preview_checkerboard.xml delete mode 100644 app/src/main/res/layout/widget_config_preview.xml delete mode 100644 app/src/main/res/layout/widget_message_layout.xml delete mode 100644 app/src/main/res/layout/widget_pod_dual_compact_layout.xml delete mode 100644 app/src/main/res/layout/widget_pod_dual_wide_layout.xml delete mode 100644 app/src/main/res/layout/widget_pod_single_layout.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eb3bc532..b1154837 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -188,6 +188,7 @@ dependencies { addNavigation() addCompose() + addGlance() addNavigation3() addSerialization() diff --git a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt index 5f9aeab7..2f01487e 100644 --- a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt +++ b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt @@ -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(), + ), ) } } diff --git a/app/src/main/java/eu/darken/capod/App.kt b/app/src/main/java/eu/darken/capod/App.kt index 32287d52..c9276ede 100644 --- a/app/src/main/java/eu/darken/capod/App.kt +++ b/app/src/main/java/eu/darken/capod/App.kt @@ -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) diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt new file mode 100644 index 00000000..902d4f80 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt @@ -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") + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeWidgetPreview.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeWidgetPreview.kt new file mode 100644 index 00000000..4dd27090 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeWidgetPreview.kt @@ -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)) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceWidgetContent.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceWidgetContent.kt new file mode 100644 index 00000000..f3f7c60e --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceWidgetContent.kt @@ -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()}%" } ?: "—" +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt index c28e9bbd..e1558031 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt @@ -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 { diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt index 28a3058a..4f8562f8 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt @@ -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(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(id)?.setTextColor(textColor) - } - for (id in iconIds) { - clipWrapper.findViewById(id)?.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN) - } - - // Device label - val labelView = clipWrapper.findViewById(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)) }, diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetManager.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetManager.kt index 4a7eb46e..f02cda53 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetManager.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetManager.kt @@ -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") } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProvider.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProvider.kt index 65b1d51e..25aa4a56 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProvider.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProvider.kt @@ -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, - iconViewIds: List, - 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) - } } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderState.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderState.kt new file mode 100644 index 00000000..2f954dc8 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderState.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt new file mode 100644 index 00000000..8d587078 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt @@ -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 + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetTheme.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetTheme.kt index fbd334e1..f9720b7a 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetTheme.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetTheme.kt @@ -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 } } diff --git a/app/src/main/res/drawable/widget_preview_bg.xml b/app/src/main/res/drawable/widget_preview_bg.xml deleted file mode 100644 index e4bec00d..00000000 --- a/app/src/main/res/drawable/widget_preview_bg.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/widget_preview_checkerboard.xml b/app/src/main/res/drawable/widget_preview_checkerboard.xml deleted file mode 100644 index 1f3cfc6e..00000000 --- a/app/src/main/res/drawable/widget_preview_checkerboard.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/widget_config_preview.xml b/app/src/main/res/layout/widget_config_preview.xml deleted file mode 100644 index dca0e403..00000000 --- a/app/src/main/res/layout/widget_config_preview.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/widget_message_layout.xml b/app/src/main/res/layout/widget_message_layout.xml deleted file mode 100644 index 68723f1d..00000000 --- a/app/src/main/res/layout/widget_message_layout.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/widget_pod_dual_compact_layout.xml b/app/src/main/res/layout/widget_pod_dual_compact_layout.xml deleted file mode 100644 index b82981c2..00000000 --- a/app/src/main/res/layout/widget_pod_dual_compact_layout.xml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/widget_pod_dual_wide_layout.xml b/app/src/main/res/layout/widget_pod_dual_wide_layout.xml deleted file mode 100644 index e2f19a41..00000000 --- a/app/src/main/res/layout/widget_pod_dual_wide_layout.xml +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/widget_pod_single_layout.xml b/app/src/main/res/layout/widget_pod_single_layout.xml deleted file mode 100644 index edcd0ae9..00000000 --- a/app/src/main/res/layout/widget_pod_single_layout.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e47c7c3a..5615695b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -123,6 +123,7 @@ This feature requires CAPod Pro. No data + Error loading widget Appearance Presets Background color diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index d89100db..4e7cce2d 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -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") diff --git a/buildSrc/src/main/java/Versions.kt b/buildSrc/src/main/java/Versions.kt index 5854e194..bb1aed87 100644 --- a/buildSrc/src/main/java/Versions.kt +++ b/buildSrc/src/main/java/Versions.kt @@ -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" }