diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 58f0f652..63eea704 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -101,6 +101,17 @@
android:resource="@xml/battery_widget_info" />
+
+
+
+
+
+
+
+ profiles.firstOrNull { it.id == pid }?.label
+ }
+
+ val widthCells = getCellsForSize(widthDp.value.toInt())
+ val heightCells = getCellsForSize(heightDp.value.toInt())
+ val layout = when {
+ widthCells <= 1 && heightCells <= 1 -> AncLayout.QUAD_CORNERS
+ heightCells <= 1 -> AncLayout.ROW_ICONS
+ widthCells <= 1 -> AncLayout.COLUMN_ICONS
+ widthCells > 2 -> AncLayout.ROW
+ heightCells > 3 -> AncLayout.COLUMN
+ else -> AncLayout.GRID_2X2
+ }
+
+ AncWidgetRenderStateMapper.map(
+ context = context,
+ device = device,
+ theme = theme,
+ isPro = isPro,
+ hasConfiguredProfile = profileId != null,
+ profileLabel = profileLabel,
+ layout = layout,
+ )
+ } catch (e: Exception) {
+ log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
+ AncWidgetRenderState.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),
+ )
+ }
+
+ GlanceAncWidgetContent(state = state, context = context, appWidgetId = appWidgetId)
+ }
+ }
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) {
+ val previewState = AncWidgetRenderState.previewActive(
+ context = context,
+ bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
+ textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
+ iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
+ activeColor = WidgetRenderStateMapper.resolveThemeColor(context, com.google.android.material.R.attr.colorSecondaryContainer),
+ onActiveColor = WidgetRenderStateMapper.resolveThemeColor(context, com.google.android.material.R.attr.colorOnSecondaryContainer),
+ )
+
+ provideContent {
+ GlanceAncWidgetContent(
+ state = previewState,
+ context = context,
+ appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID,
+ )
+ }
+ }
+
+ private fun appWidgetId(context: Context, id: GlanceId): Int {
+ return try {
+ GlanceAppWidgetManager(context).getAppWidgetId(id)
+ } catch (_: Exception) {
+ AppWidgetManager.INVALID_APPWIDGET_ID
+ }
+ }
+
+ private fun getCellsForSize(size: Int): Int {
+ var n = 2
+ while (70 * n - 30 < size) {
+ ++n
+ }
+ return n - 1
+ }
+
+ companion object {
+ val TAG = logTag("Widget", "ANC", "Glance")
+ }
+}
diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/AncModeActionCallback.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/AncModeActionCallback.kt
new file mode 100644
index 00000000..03924771
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/main/ui/widget/AncModeActionCallback.kt
@@ -0,0 +1,91 @@
+package eu.darken.capod.main.ui.widget
+
+import android.content.Context
+import androidx.annotation.Keep
+import androidx.glance.GlanceId
+import androidx.glance.action.ActionParameters
+import androidx.glance.appwidget.action.ActionCallback
+import androidx.glance.appwidget.updateAll
+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.monitor.core.DeviceMonitor
+import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
+import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
+import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
+
+@Keep
+class AncModeActionCallback : ActionCallback {
+
+ @Keep
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface AncModeCallbackEntryPoint {
+ fun aapConnectionManager(): AapConnectionManager
+ fun widgetSettings(): WidgetSettings
+ fun deviceMonitor(): DeviceMonitor
+ }
+
+ override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
+ val modeName = parameters[ANC_MODE_KEY] ?: run {
+ log(TAG, ERROR) { "onAction: missing ANC_MODE_KEY" }
+ return
+ }
+ val widgetId = parameters[WIDGET_ID_KEY] ?: run {
+ log(TAG, ERROR) { "onAction: missing WIDGET_ID_KEY" }
+ return
+ }
+
+ log(TAG, VERBOSE) { "onAction(mode=$modeName, widgetId=$widgetId)" }
+
+ val mode = try {
+ enumValueOf(modeName)
+ } catch (e: IllegalArgumentException) {
+ log(TAG, ERROR) { "onAction: invalid mode name: $modeName" }
+ return
+ }
+
+ val ep = EntryPointAccessors.fromApplication(context, AncModeCallbackEntryPoint::class.java)
+
+ val profileId = ep.widgetSettings().getWidgetProfile(widgetId)
+ if (profileId == null) {
+ log(TAG, ERROR) { "onAction: no profile for widgetId=$widgetId" }
+ return
+ }
+
+ val device = ep.deviceMonitor().getDeviceForProfile(profileId)
+ if (device == null) {
+ log(TAG, ERROR) { "onAction: no device for profileId=$profileId" }
+ AncGlanceWidget().updateAll(context)
+ return
+ }
+
+ val address = device.address
+ if (address == null) {
+ log(TAG, ERROR) { "onAction: device has no address" }
+ AncGlanceWidget().updateAll(context)
+ return
+ }
+
+ try {
+ ep.aapConnectionManager().sendCommand(address, AapCommand.SetAncMode(mode))
+ log(TAG, VERBOSE) { "onAction: sent SetAncMode($mode) to $address" }
+ } catch (e: Exception) {
+ log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" }
+ }
+
+ AncGlanceWidget().updateAll(context)
+ }
+
+ companion object {
+ val ANC_MODE_KEY = ActionParameters.Key("anc_mode")
+ val WIDGET_ID_KEY = ActionParameters.Key("widget_id")
+ private val TAG = logTag("Widget", "ANC", "Callback")
+ }
+}
diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetProvider.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetProvider.kt
new file mode 100644
index 00000000..05391aeb
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetProvider.kt
@@ -0,0 +1,31 @@
+package eu.darken.capod.main.ui.widget
+
+import android.content.Context
+import androidx.annotation.Keep
+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
+
+@Keep
+class AncWidgetProvider : GlanceAppWidgetReceiver() {
+
+ override val glanceAppWidget: GlanceAppWidget = AncGlanceWidget()
+
+ override fun onDeleted(context: Context, appWidgetIds: IntArray) {
+ log(TAG) { "onDeleted(appWidgetIds=${appWidgetIds.toList()})" }
+ super.onDeleted(context, appWidgetIds)
+ val ep = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ AncGlanceWidget.AncWidgetEntryPoint::class.java,
+ )
+ appWidgetIds.forEach { widgetId ->
+ ep.widgetSettings().removeWidget(widgetId)
+ }
+ }
+
+ companion object {
+ private val TAG = logTag("Widget", "ANC", "Provider")
+ }
+}
diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderState.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderState.kt
new file mode 100644
index 00000000..751d686a
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderState.kt
@@ -0,0 +1,91 @@
+package eu.darken.capod.main.ui.widget
+
+import android.content.Context
+import androidx.annotation.ColorInt
+import androidx.annotation.DrawableRes
+import eu.darken.capod.R
+import eu.darken.capod.main.ui.components.shortLabel
+import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
+
+sealed class AncWidgetRenderState {
+ abstract val theme: WidgetTheme
+ @get:ColorInt abstract val resolvedBgColor: Int
+ @get:ColorInt abstract val resolvedTextColor: Int
+ @get:ColorInt abstract val resolvedIconColor: Int
+
+ data class Active(
+ override val theme: WidgetTheme,
+ @ColorInt override val resolvedBgColor: Int,
+ @ColorInt override val resolvedTextColor: Int,
+ @ColorInt override val resolvedIconColor: Int,
+ @ColorInt val resolvedActiveColor: Int,
+ @ColorInt val resolvedOnActiveColor: Int,
+ val modes: List,
+ val deviceLabel: String?,
+ val layout: AncLayout,
+ ) : AncWidgetRenderState()
+
+ 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,
+ ) : AncWidgetRenderState()
+
+ companion object {
+ fun previewActive(
+ context: Context,
+ theme: WidgetTheme = WidgetTheme.DEFAULT,
+ @ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
+ @ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
+ @ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
+ @ColorInt activeColor: Int = 0xFFCCE5FF.toInt(),
+ @ColorInt onActiveColor: Int = 0xFF001E30.toInt(),
+ layout: AncLayout = AncLayout.GRID_2X2,
+ modes: List = AapSetting.AncMode.Value.entries,
+ activeMode: AapSetting.AncMode.Value = AapSetting.AncMode.Value.ON,
+ deviceLabel: String? = null,
+ ): Active = Active(
+ theme = theme,
+ resolvedBgColor = bgColor,
+ resolvedTextColor = textColor,
+ resolvedIconColor = iconColor,
+ resolvedActiveColor = activeColor,
+ resolvedOnActiveColor = onActiveColor,
+ modes = modes.map { mode ->
+ ModeItem(
+ mode = mode,
+ label = mode.shortLabel(context),
+ iconRes = mode.previewIconRes(),
+ state = if (mode == activeMode) ButtonState.ACTIVE else ButtonState.INACTIVE,
+ )
+ },
+ deviceLabel = deviceLabel,
+ layout = layout,
+ )
+ }
+}
+
+data class ModeItem(
+ val mode: AapSetting.AncMode.Value,
+ val label: String,
+ @DrawableRes val iconRes: Int,
+ val state: ButtonState,
+)
+
+enum class ButtonState {
+ ACTIVE, PENDING, INACTIVE,
+}
+
+enum class AncLayout {
+ QUAD_CORNERS, ROW_ICONS, COLUMN_ICONS, GRID_2X2, ROW, COLUMN,
+}
+
+private fun AapSetting.AncMode.Value.previewIconRes(): Int = when (this) {
+ AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
+ AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
+ AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
+ AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
+}
diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderStateMapper.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderStateMapper.kt
new file mode 100644
index 00000000..4359a578
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderStateMapper.kt
@@ -0,0 +1,130 @@
+package eu.darken.capod.main.ui.widget
+
+import android.content.Context
+import eu.darken.capod.R
+import eu.darken.capod.main.ui.components.shortLabel
+import eu.darken.capod.monitor.core.PodDevice
+import eu.darken.capod.monitor.core.visibleAncModes
+import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
+
+object AncWidgetRenderStateMapper {
+
+ fun map(
+ context: Context,
+ device: PodDevice?,
+ theme: WidgetTheme,
+ isPro: Boolean,
+ hasConfiguredProfile: Boolean,
+ profileLabel: String?,
+ layout: AncLayout,
+ ): AncWidgetRenderState {
+ val bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme)
+ val textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme)
+ val iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme)
+ val activeColor = WidgetRenderStateMapper.resolveThemeColor(context, com.google.android.material.R.attr.colorSecondaryContainer)
+ val onActiveColor = WidgetRenderStateMapper.resolveThemeColor(context, com.google.android.material.R.attr.colorOnSecondaryContainer)
+
+ return when {
+ !isPro -> AncWidgetRenderState.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 == null -> {
+ val messageRes = if (hasConfiguredProfile) {
+ R.string.widget_no_data_label
+ } else {
+ R.string.overview_nomaindevice_label
+ }
+ AncWidgetRenderState.Message(
+ theme = theme,
+ resolvedBgColor = bgColor,
+ resolvedTextColor = textColor,
+ resolvedIconColor = iconColor,
+ primaryText = context.getString(messageRes),
+ )
+ }
+
+ !device.hasAncControl -> AncWidgetRenderState.Message(
+ theme = theme,
+ resolvedBgColor = bgColor,
+ resolvedTextColor = textColor,
+ resolvedIconColor = iconColor,
+ primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
+ )
+
+ !device.isAapConnected -> AncWidgetRenderState.Message(
+ theme = theme,
+ resolvedBgColor = bgColor,
+ resolvedTextColor = textColor,
+ resolvedIconColor = iconColor,
+ primaryText = context.getString(R.string.anc_widget_aap_not_connected_label),
+ )
+
+ !device.isAapReady -> AncWidgetRenderState.Message(
+ theme = theme,
+ resolvedBgColor = bgColor,
+ resolvedTextColor = textColor,
+ resolvedIconColor = iconColor,
+ primaryText = context.getString(R.string.anc_widget_aap_connecting_label),
+ )
+
+ else -> {
+ val ancMode = device.ancMode ?: return AncWidgetRenderState.Message(
+ theme = theme,
+ resolvedBgColor = bgColor,
+ resolvedTextColor = textColor,
+ resolvedIconColor = iconColor,
+ primaryText = context.getString(R.string.anc_widget_aap_connecting_label),
+ )
+
+ val currentMode = ancMode.current
+ val pendingMode = device.pendingAncMode
+
+ val filteredModes = device.visibleAncModes
+
+ // If the pending mode has been filtered out of visibleAncModes (e.g. device
+ // reports AllowOff=false while OFF is still pending), fall back to currentMode
+ // for the ACTIVE highlight so the widget never renders with zero active buttons.
+ val pendingVisible = pendingMode != null && filteredModes.contains(pendingMode)
+ val displayMode = if (pendingVisible) pendingMode else currentMode
+
+ val modeItems = filteredModes.map { mode ->
+ ModeItem(
+ mode = mode,
+ label = mode.shortLabel(context),
+ iconRes = mode.iconDrawableRes(),
+ state = when {
+ pendingVisible && mode == pendingMode -> ButtonState.PENDING
+ mode == displayMode -> ButtonState.ACTIVE
+ else -> ButtonState.INACTIVE
+ },
+ )
+ }
+
+ AncWidgetRenderState.Active(
+ theme = theme,
+ resolvedBgColor = bgColor,
+ resolvedTextColor = textColor,
+ resolvedIconColor = iconColor,
+ resolvedActiveColor = activeColor,
+ resolvedOnActiveColor = onActiveColor,
+ modes = modeItems,
+ deviceLabel = profileLabel ?: device.getLabel(context),
+ layout = layout,
+ )
+ }
+ }
+ }
+}
+
+private fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
+ AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
+ AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
+ AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
+ AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
+}
diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeAncWidgetPreview.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeAncWidgetPreview.kt
new file mode 100644
index 00000000..acb5735c
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeAncWidgetPreview.kt
@@ -0,0 +1,142 @@
+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.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.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.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun ComposeAncWidgetPreview(
+ state: AncWidgetRenderState,
+ modifier: Modifier = Modifier,
+) {
+ when (state) {
+ is AncWidgetRenderState.Active -> ActiveAncPreview(state, modifier)
+ is AncWidgetRenderState.Message -> MessageAncPreview(state, modifier)
+ }
+}
+
+@Composable
+private fun ActiveAncPreview(
+ state: AncWidgetRenderState.Active,
+ modifier: Modifier,
+) {
+ Column(
+ modifier = modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(Color(state.resolvedBgColor))
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ AncGridRow(state.modes.take(2), state.resolvedIconColor, state.resolvedBgColor)
+ if (state.modes.size > 2) {
+ AncGridRow(state.modes.drop(2), state.resolvedIconColor, state.resolvedBgColor)
+ }
+ if (state.theme.showDeviceLabel && state.deviceLabel != null) {
+ Text(
+ text = state.deviceLabel,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color(state.resolvedTextColor),
+ textAlign = TextAlign.Center,
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+@Composable
+private fun AncGridRow(
+ items: List,
+ resolvedIconColor: Int,
+ resolvedBgColor: Int,
+) {
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ items.forEach { item ->
+ AncPreviewButton(item, resolvedIconColor, resolvedBgColor)
+ }
+ }
+}
+
+@Composable
+private fun AncPreviewButton(
+ item: ModeItem,
+ resolvedIconColor: Int,
+ resolvedBgColor: Int,
+) {
+ val bgColor = when (item.state) {
+ ButtonState.ACTIVE -> Color(resolvedIconColor)
+ ButtonState.PENDING -> Color(resolvedIconColor).copy(alpha = 0.6f)
+ ButtonState.INACTIVE -> Color(resolvedIconColor).copy(alpha = 0.12f)
+ }
+ val tint = when (item.state) {
+ ButtonState.ACTIVE, ButtonState.PENDING -> Color(resolvedBgColor)
+ ButtonState.INACTIVE -> Color(resolvedIconColor)
+ }
+ Box(
+ modifier = Modifier
+ .padding(4.dp)
+ .size(48.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(bgColor),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = painterResource(item.iconRes),
+ contentDescription = item.label,
+ colorFilter = ColorFilter.tint(tint),
+ modifier = Modifier.size(24.dp),
+ )
+ }
+}
+
+@Composable
+private fun MessageAncPreview(
+ state: AncWidgetRenderState.Message,
+ modifier: Modifier,
+) {
+ Column(
+ modifier = modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(Color(state.resolvedBgColor))
+ .padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = state.primaryText,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color(state.resolvedTextColor),
+ textAlign = TextAlign.Center,
+ )
+ if (state.secondaryText != null) {
+ Text(
+ text = state.secondaryText,
+ fontSize = 12.sp,
+ color = Color(state.resolvedTextColor),
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceAncWidgetContent.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceAncWidgetContent.kt
new file mode 100644
index 00000000..60642535
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceAncWidgetContent.kt
@@ -0,0 +1,569 @@
+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.ActionParameters
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.action.actionRunCallback
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.ColumnScope
+import androidx.glance.layout.Row
+import androidx.glance.layout.RowScope
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxHeight
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.layout.width
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextAlign
+import androidx.glance.text.TextStyle
+import androidx.glance.unit.ColorProvider
+import eu.darken.capod.main.ui.MainActivity
+
+@Composable
+fun GlanceAncWidgetContent(
+ state: AncWidgetRenderState,
+ context: android.content.Context,
+ appWidgetId: Int,
+) {
+ val openApp = actionStartActivity(
+ Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ )
+
+ when (state) {
+ is AncWidgetRenderState.Active -> GlanceAncActive(state, appWidgetId)
+ is AncWidgetRenderState.Message -> GlanceAncMessage(state, GlanceModifier.clickable(openApp))
+ }
+}
+
+@Composable
+private fun GlanceAncActive(
+ state: AncWidgetRenderState.Active,
+ appWidgetId: Int,
+) {
+ val textColor = fixedAncColor(state.resolvedTextColor)
+ val iconColor = fixedAncColor(state.resolvedIconColor)
+ val showDeviceLabel = state.layout != AncLayout.ROW_ICONS
+ && state.layout != AncLayout.COLUMN_ICONS
+ && state.layout != AncLayout.QUAD_CORNERS
+
+ AncWidgetRoot(state.resolvedBgColor) {
+ when (state.layout) {
+ AncLayout.GRID_2X2 -> AncGrid(state.modes, iconColor, state.resolvedIconColor, state.resolvedActiveColor, state.resolvedOnActiveColor, appWidgetId)
+ AncLayout.ROW -> AncRow(state.modes, iconColor, textColor, state.resolvedIconColor, state.resolvedActiveColor, state.resolvedOnActiveColor, appWidgetId)
+ AncLayout.COLUMN -> AncColumn(state.modes, iconColor, textColor, state.resolvedIconColor, state.resolvedActiveColor, state.resolvedOnActiveColor, appWidgetId)
+ AncLayout.ROW_ICONS -> AncRowIcons(state.modes, state.resolvedIconColor, state.resolvedActiveColor, state.resolvedOnActiveColor, appWidgetId)
+ AncLayout.COLUMN_ICONS -> AncColumnIcons(state.modes, state.resolvedIconColor, state.resolvedActiveColor, state.resolvedOnActiveColor, appWidgetId)
+ AncLayout.QUAD_CORNERS -> AncQuadCorners(state.modes, state.resolvedIconColor, state.resolvedActiveColor, state.resolvedOnActiveColor, appWidgetId)
+ }
+ if (showDeviceLabel) {
+ GlanceAncDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
+ }
+ }
+}
+
+@Composable
+private fun AncRowIcons(
+ modes: List,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+ appWidgetId: Int,
+) {
+ val dividerColor = fixedAncColor(applyAncAlpha(resolvedIconColor, 60))
+ Row(
+ modifier = GlanceModifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ modes.forEachIndexed { index, item ->
+ if (index > 0) {
+ Spacer(modifier = GlanceModifier.width(1.dp).fillMaxHeight().background(dividerColor))
+ }
+ DividerRowCell(item, resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor, appWidgetId)
+ }
+ }
+}
+
+@Composable
+private fun AncColumnIcons(
+ modes: List,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+ appWidgetId: Int,
+) {
+ val dividerColor = fixedAncColor(applyAncAlpha(resolvedIconColor, 60))
+ Column(
+ modifier = GlanceModifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ modes.forEachIndexed { index, item ->
+ if (index > 0) {
+ Spacer(modifier = GlanceModifier.fillMaxWidth().height(1.dp).background(dividerColor))
+ }
+ DividerColumnCell(item, resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor, appWidgetId)
+ }
+ }
+}
+
+@Composable
+private fun RowScope.DividerRowCell(
+ item: ModeItem,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+ appWidgetId: Int,
+) {
+ Box(
+ modifier = GlanceModifier
+ .defaultWeight()
+ .fillMaxHeight()
+ .clickable(ancModeAction(item, appWidgetId)),
+ contentAlignment = Alignment.Center,
+ ) {
+ DividerCellIcon(item, resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor)
+ }
+}
+
+@Composable
+private fun ColumnScope.DividerColumnCell(
+ item: ModeItem,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+ appWidgetId: Int,
+) {
+ Box(
+ modifier = GlanceModifier
+ .defaultWeight()
+ .fillMaxWidth()
+ .clickable(ancModeAction(item, appWidgetId)),
+ contentAlignment = Alignment.Center,
+ ) {
+ DividerCellIcon(item, resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor)
+ }
+}
+
+@Composable
+private fun DividerCellIcon(
+ item: ModeItem,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+) {
+ val bgModifier = when (item.state) {
+ ButtonState.ACTIVE -> GlanceModifier.background(fixedAncColor(resolvedActiveColor))
+ ButtonState.PENDING -> GlanceModifier.background(fixedAncColor(applyAncAlpha(resolvedActiveColor, 153)))
+ ButtonState.INACTIVE -> GlanceModifier
+ }
+ val tint = when (item.state) {
+ ButtonState.ACTIVE, ButtonState.PENDING -> ColorFilter.tint(fixedAncColor(resolvedOnActiveColor))
+ ButtonState.INACTIVE -> ColorFilter.tint(fixedAncColor(applyAncAlpha(resolvedIconColor, 160)))
+ }
+ Box(
+ modifier = bgModifier
+ .size(36.dp)
+ .cornerRadius(18.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ provider = ImageProvider(item.iconRes),
+ contentDescription = item.label,
+ modifier = GlanceModifier.size(22.dp),
+ colorFilter = tint,
+ )
+ }
+}
+
+@Composable
+private fun AncQuadCorners(
+ modes: List,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+ appWidgetId: Int,
+) {
+ val dividerColor = fixedAncColor(applyAncAlpha(resolvedIconColor, 60))
+
+ Column(modifier = GlanceModifier.fillMaxSize()) {
+ Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
+ QuadCornerCell(modes.getOrNull(0), resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor, appWidgetId)
+ Spacer(modifier = GlanceModifier.width(1.dp).fillMaxHeight().background(dividerColor))
+ QuadCornerCell(modes.getOrNull(1), resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor, appWidgetId)
+ }
+ Spacer(modifier = GlanceModifier.fillMaxWidth().height(1.dp).background(dividerColor))
+ Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
+ QuadCornerCell(modes.getOrNull(2), resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor, appWidgetId)
+ Spacer(modifier = GlanceModifier.width(1.dp).fillMaxHeight().background(dividerColor))
+ QuadCornerCell(modes.getOrNull(3), resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor, appWidgetId)
+ }
+ }
+}
+
+@Composable
+private fun RowScope.QuadCornerCell(
+ item: ModeItem?,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+ appWidgetId: Int,
+) {
+ val cellModifier = GlanceModifier.defaultWeight().fillMaxHeight()
+ val clickable = if (item != null) cellModifier.clickable(ancModeAction(item, appWidgetId)) else cellModifier
+ Box(
+ modifier = clickable,
+ contentAlignment = Alignment.Center,
+ ) {
+ if (item != null) {
+ DividerCellIcon(item, resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor)
+ }
+ }
+}
+
+@Composable
+private fun AncGrid(
+ modes: List,
+ iconColor: ColorProvider,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+ appWidgetId: Int,
+) {
+ val firstRow = modes.take(2)
+ val secondRow = modes.drop(2)
+
+ Column(
+ modifier = GlanceModifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ firstRow.forEachIndexed { index, item ->
+ if (index > 0) Spacer(modifier = GlanceModifier.width(10.dp))
+ AncGridButton(item, iconColor, resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor, appWidgetId)
+ }
+ }
+ if (secondRow.isNotEmpty()) {
+ Spacer(modifier = GlanceModifier.height(10.dp))
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ secondRow.forEachIndexed { index, item ->
+ if (index > 0) Spacer(modifier = GlanceModifier.width(10.dp))
+ AncGridButton(item, iconColor, resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor, appWidgetId)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AncGridButton(
+ item: ModeItem,
+ iconColor: ColorProvider,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+ appWidgetId: Int,
+) {
+ val bgModifier = when (item.state) {
+ ButtonState.ACTIVE -> GlanceModifier.background(fixedAncColor(resolvedActiveColor))
+ ButtonState.PENDING -> GlanceModifier.background(fixedAncColor(applyAncAlpha(resolvedActiveColor, 153)))
+ ButtonState.INACTIVE -> GlanceModifier.background(fixedAncColor(applyAncAlpha(resolvedIconColor, 30)))
+ }
+ val tint = when (item.state) {
+ ButtonState.ACTIVE -> ColorFilter.tint(fixedAncColor(resolvedOnActiveColor))
+ ButtonState.PENDING -> ColorFilter.tint(fixedAncColor(resolvedOnActiveColor))
+ ButtonState.INACTIVE -> ColorFilter.tint(iconColor)
+ }
+
+ Box(
+ modifier = bgModifier
+ .padding(4.dp)
+ .size(48.dp)
+ .cornerRadius(12.dp)
+ .clickable(ancModeAction(item, appWidgetId)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ provider = ImageProvider(item.iconRes),
+ contentDescription = item.label,
+ modifier = GlanceModifier.size(24.dp),
+ colorFilter = tint,
+ )
+ }
+}
+
+@Composable
+private fun AncRow(
+ modes: List,
+ iconColor: ColorProvider,
+ textColor: ColorProvider,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+ appWidgetId: Int,
+) {
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ modes.forEachIndexed { index, item ->
+ if (index > 0) Spacer(modifier = GlanceModifier.width(6.dp))
+ AncLabeledButton(item, iconColor, textColor, resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor, appWidgetId)
+ }
+ }
+}
+
+@Composable
+private fun AncColumn(
+ modes: List,
+ iconColor: ColorProvider,
+ textColor: ColorProvider,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+ appWidgetId: Int,
+) {
+ Column(
+ modifier = GlanceModifier.fillMaxSize().padding(vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ modes.forEachIndexed { index, item ->
+ if (index > 0) Spacer(modifier = GlanceModifier.height(6.dp))
+ AncColumnItem(item, iconColor, textColor, resolvedIconColor, resolvedActiveColor, resolvedOnActiveColor, appWidgetId)
+ }
+ }
+}
+
+@Composable
+private fun ColumnScope.AncColumnItem(
+ item: ModeItem,
+ iconColor: ColorProvider,
+ textColor: ColorProvider,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+ appWidgetId: Int,
+) {
+ val bgModifier = when (item.state) {
+ ButtonState.ACTIVE -> GlanceModifier.background(fixedAncColor(resolvedActiveColor))
+ ButtonState.PENDING -> GlanceModifier.background(fixedAncColor(applyAncAlpha(resolvedActiveColor, 153)))
+ ButtonState.INACTIVE -> GlanceModifier.background(fixedAncColor(applyAncAlpha(resolvedIconColor, 30)))
+ }
+ val tint = when (item.state) {
+ ButtonState.ACTIVE, ButtonState.PENDING -> ColorFilter.tint(fixedAncColor(resolvedOnActiveColor))
+ ButtonState.INACTIVE -> ColorFilter.tint(iconColor)
+ }
+ val labelColor = when (item.state) {
+ ButtonState.ACTIVE, ButtonState.PENDING -> fixedAncColor(resolvedOnActiveColor)
+ ButtonState.INACTIVE -> textColor
+ }
+ Row(
+ modifier = bgModifier
+ .fillMaxWidth()
+ .defaultWeight()
+ .cornerRadius(12.dp)
+ .clickable(ancModeAction(item, appWidgetId))
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Image(
+ provider = ImageProvider(item.iconRes),
+ contentDescription = null,
+ modifier = GlanceModifier.size(18.dp),
+ colorFilter = tint,
+ )
+ Spacer(modifier = GlanceModifier.width(6.dp))
+ Text(
+ text = item.label,
+ style = TextStyle(color = labelColor, fontSize = 12.sp, fontWeight = FontWeight.Medium),
+ )
+ }
+}
+
+@Composable
+private fun AncLabeledButton(
+ item: ModeItem,
+ iconColor: ColorProvider,
+ textColor: ColorProvider,
+ resolvedIconColor: Int,
+ resolvedActiveColor: Int,
+ resolvedOnActiveColor: Int,
+ appWidgetId: Int,
+ horizontal: Boolean = false,
+) {
+ val bgModifier = when (item.state) {
+ ButtonState.ACTIVE -> GlanceModifier.background(fixedAncColor(resolvedActiveColor))
+ ButtonState.PENDING -> GlanceModifier.background(fixedAncColor(applyAncAlpha(resolvedActiveColor, 153)))
+ ButtonState.INACTIVE -> GlanceModifier.background(fixedAncColor(applyAncAlpha(resolvedIconColor, 30)))
+ }
+ val tint = when (item.state) {
+ ButtonState.ACTIVE -> ColorFilter.tint(fixedAncColor(resolvedOnActiveColor))
+ ButtonState.PENDING -> ColorFilter.tint(fixedAncColor(resolvedOnActiveColor))
+ ButtonState.INACTIVE -> ColorFilter.tint(iconColor)
+ }
+ val labelColor = when (item.state) {
+ ButtonState.ACTIVE -> fixedAncColor(resolvedOnActiveColor)
+ ButtonState.PENDING -> fixedAncColor(resolvedOnActiveColor)
+ ButtonState.INACTIVE -> textColor
+ }
+
+ if (horizontal) {
+ Row(
+ modifier = bgModifier
+ .fillMaxWidth()
+ .cornerRadius(12.dp)
+ .clickable(ancModeAction(item, appWidgetId))
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Image(
+ provider = ImageProvider(item.iconRes),
+ contentDescription = null,
+ modifier = GlanceModifier.size(18.dp),
+ colorFilter = tint,
+ )
+ Spacer(modifier = GlanceModifier.width(6.dp))
+ Text(
+ text = item.label,
+ style = TextStyle(color = labelColor, fontSize = 12.sp, fontWeight = FontWeight.Medium),
+ )
+ }
+ } else {
+ Column(
+ modifier = bgModifier
+ .padding(horizontal = 14.dp, vertical = 10.dp)
+ .cornerRadius(14.dp)
+ .clickable(ancModeAction(item, appWidgetId)),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ provider = ImageProvider(item.iconRes),
+ contentDescription = null,
+ modifier = GlanceModifier.size(22.dp),
+ colorFilter = tint,
+ )
+ Spacer(modifier = GlanceModifier.height(2.dp))
+ Text(
+ text = item.label,
+ style = TextStyle(color = labelColor, fontSize = 12.sp, fontWeight = FontWeight.Medium),
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+@Composable
+private fun GlanceAncMessage(
+ state: AncWidgetRenderState.Message,
+ clickModifier: GlanceModifier,
+) {
+ val textStyle = TextStyle(
+ color = fixedAncColor(state.resolvedTextColor),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ )
+
+ AncWidgetRoot(state.resolvedBgColor, clickModifier) {
+ Text(
+ text = state.primaryText,
+ style = textStyle,
+ modifier = GlanceModifier.fillMaxWidth(),
+ )
+ if (state.secondaryText != null) {
+ Text(
+ text = state.secondaryText,
+ style = TextStyle(
+ color = fixedAncColor(state.resolvedTextColor),
+ fontSize = 12.sp,
+ textAlign = TextAlign.Center,
+ ),
+ modifier = GlanceModifier.fillMaxWidth(),
+ maxLines = 4,
+ )
+ }
+ }
+}
+
+@Composable
+private fun AncWidgetRoot(
+ bgColor: Int,
+ extraModifier: GlanceModifier = GlanceModifier,
+ content: @Composable () -> Unit,
+) {
+ Column(
+ modifier = extraModifier
+ .fillMaxSize()
+ .background(fixedAncColor(bgColor))
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ content()
+ }
+}
+
+@Composable
+private fun GlanceAncDeviceLabel(
+ label: String?,
+ visible: Boolean,
+ textColor: Int,
+) {
+ if (visible && label != null) {
+ Text(
+ text = label,
+ style = TextStyle(
+ color = fixedAncColor(textColor),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ ),
+ maxLines = 1,
+ modifier = GlanceModifier.padding(top = 8.dp),
+ )
+ }
+}
+
+private fun fixedAncColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
+
+private fun applyAncAlpha(color: Int, alpha: Int): Int {
+ return android.graphics.Color.argb(
+ alpha,
+ android.graphics.Color.red(color),
+ android.graphics.Color.green(color),
+ android.graphics.Color.blue(color),
+ )
+}
+
+private fun ancModeAction(item: ModeItem, appWidgetId: Int) = actionRunCallback(
+ parameters = androidx.glance.action.actionParametersOf(
+ AncModeActionCallback.ANC_MODE_KEY to item.mode.name,
+ AncModeActionCallback.WIDGET_ID_KEY to appWidgetId,
+ )
+)
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 4de33873..8fdb54ef 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
@@ -74,6 +74,7 @@ class WidgetConfigurationActivity : Activity2() {
state?.let { currentState ->
WidgetConfigurationScreen(
state = currentState,
+ showAapRequiredHint = currentState.isAncWidget,
onSelectProfile = { profile -> vm.selectProfile(profile.id) },
onSelectPreset = { preset -> vm.selectPreset(preset) },
onEnterCustomMode = { bg, fg -> vm.enterCustomMode(bg, fg) },
@@ -84,7 +85,7 @@ class WidgetConfigurationActivity : Activity2() {
onReset = { vm.resetToDefaults() },
onConfirm = {
if (currentState.isPro) {
- confirmSelection()
+ confirmSelection(currentState.isAncWidget)
} else {
startActivity(
Intent(this@WidgetConfigurationActivity, MainActivity::class.java).apply {
@@ -100,7 +101,7 @@ class WidgetConfigurationActivity : Activity2() {
}
}
- private fun confirmSelection() {
+ private fun confirmSelection(isAncWidget: Boolean) {
vm.confirmSelection()
val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
@@ -109,7 +110,11 @@ class WidgetConfigurationActivity : Activity2() {
lifecycleScope.launch {
val manager = GlanceAppWidgetManager(appContext)
val glanceId = manager.getGlanceIdBy(widgetId)
- BatteryGlanceWidget().update(appContext, glanceId)
+ if (isAncWidget) {
+ AncGlanceWidget().update(appContext, glanceId)
+ } else {
+ BatteryGlanceWidget().update(appContext, glanceId)
+ }
finish()
}
}
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 6c0ddc22..c49e6748 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
@@ -84,6 +84,7 @@ import eu.darken.capod.profiles.core.DeviceProfile
@Composable
fun WidgetConfigurationScreen(
state: WidgetConfigurationViewModel.State,
+ showAapRequiredHint: Boolean = false,
onSelectProfile: (DeviceProfile) -> Unit,
onSelectPreset: (WidgetTheme.Preset) -> Unit,
onEnterCustomMode: (defaultBg: Int, defaultFg: Int) -> Unit,
@@ -118,6 +119,26 @@ fun WidgetConfigurationScreen(
.padding(start = screenHPad, end = screenHPad, bottom = 16.dp),
)
+ if (showAapRequiredHint) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = screenHPad),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer,
+ ),
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.anc_widget_config_aap_required_hint),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onTertiaryContainer,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
// Card A — Select Device
Card(
modifier = Modifier
@@ -186,6 +207,17 @@ fun WidgetConfigurationScreen(
WidgetConfigPreview(
theme = state.theme,
deviceLabel = state.profiles.firstOrNull { it.id == state.selectedProfile }?.label,
+ isAncWidget = state.isAncWidget,
+ )
+
+ Text(
+ text = stringResource(R.string.widget_config_preview_resize_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
)
Spacer(modifier = Modifier.height(16.dp))
@@ -435,20 +467,12 @@ private fun ProfileSelectionItem(
private fun WidgetConfigPreview(
theme: WidgetTheme,
deviceLabel: String?,
+ isAncWidget: Boolean,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val hasTransparency = theme.backgroundColor != null && theme.backgroundAlpha < 255
- 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(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
@@ -461,15 +485,39 @@ private fun WidgetConfigPreview(
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
+ val previewContent: @Composable (Modifier) -> Unit = { innerModifier ->
+ if (isAncWidget) {
+ val ancState = remember(theme, deviceLabel, context) {
+ AncWidgetRenderState.previewActive(
+ context = context,
+ theme = theme,
+ bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
+ textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
+ iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
+ activeColor = WidgetRenderStateMapper.resolveThemeColor(context, com.google.android.material.R.attr.colorSecondaryContainer),
+ onActiveColor = WidgetRenderStateMapper.resolveThemeColor(context, com.google.android.material.R.attr.colorOnSecondaryContainer),
+ deviceLabel = deviceLabel,
+ )
+ }
+ ComposeAncWidgetPreview(state = ancState, modifier = innerModifier)
+ } else {
+ val batteryState = 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)
+ }
+ ComposeWidgetPreview(state = batteryState, modifier = innerModifier)
+ }
+ }
if (hasTransparency) {
CheckerboardBackground {
- ComposeWidgetPreview(
- state = previewState,
- modifier = Modifier.padding(6.dp),
- )
+ previewContent(Modifier.padding(6.dp))
}
} else {
- ComposeWidgetPreview(state = previewState)
+ previewContent(Modifier)
}
}
}
diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt
index 165a1191..bb1084da 100644
--- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt
+++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt
@@ -1,6 +1,7 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
+import android.content.ComponentName
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -16,6 +17,7 @@ import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.map
import javax.inject.Inject
@HiltViewModel
@@ -33,8 +35,15 @@ class WidgetConfigurationViewModel @Inject constructor(
val widgetId: Int
get() = savedStateHandle.get(AppWidgetManager.EXTRA_APPWIDGET_ID) ?: AppWidgetManager.INVALID_APPWIDGET_ID
+ private val isAncWidget: Boolean = run {
+ if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run false
+ val info = appWidgetManager.getAppWidgetInfo(widgetId) ?: return@run false
+ val ancProvider = ComponentName(context, AncWidgetProvider::class.java)
+ info.provider == ancProvider
+ }
+
init {
- log(TAG) { "ViewModel init(widgetId=$widgetId)" }
+ log(TAG) { "ViewModel init(widgetId=$widgetId, isAncWidget=$isAncWidget)" }
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
log(TAG) { "Invalid widget ID" }
@@ -52,14 +61,18 @@ class WidgetConfigurationViewModel @Inject constructor(
private val currentTheme = MutableStateFlow(initialTheme)
+ private val visibleProfiles = deviceProfilesRepo.profiles.map { profiles ->
+ if (isAncWidget) profiles.filter { it.model.features.hasAncControl } else profiles
+ }
+
val state = combine(
selectedProfile,
currentTheme,
forceCustomMode,
- deviceProfilesRepo.profiles,
+ visibleProfiles,
upgradeRepo.upgradeInfo,
) { selected, theme, forceCustom, profiles, upgradeInfo ->
- log(TAG) { "state update: profile=$selected, theme=$theme, forceCustom=$forceCustom" }
+ log(TAG) { "state update: profile=$selected, theme=$theme, forceCustom=$forceCustom, profiles=${profiles.size}" }
val activePreset = if (forceCustom) null else WidgetTheme.matchPreset(theme)
State(
@@ -69,6 +82,7 @@ class WidgetConfigurationViewModel @Inject constructor(
theme = theme,
activePreset = activePreset,
isCustomMode = activePreset == null,
+ isAncWidget = isAncWidget,
)
}.asLiveState()
@@ -79,8 +93,9 @@ class WidgetConfigurationViewModel @Inject constructor(
val theme: WidgetTheme = WidgetTheme.DEFAULT,
val activePreset: WidgetTheme.Preset? = WidgetTheme.Preset.MATERIAL_YOU,
val isCustomMode: Boolean = false,
+ val isAncWidget: Boolean = false,
) {
- val canConfirm: Boolean = selectedProfile != null
+ val canConfirm: Boolean = selectedProfile != null && profiles.any { it.id == selectedProfile }
}
fun selectProfile(profileId: ProfileId) {
@@ -125,12 +140,6 @@ class WidgetConfigurationViewModel @Inject constructor(
currentTheme.value = currentTheme.value.copy(backgroundAlpha = alpha.coerceIn(0, 255))
}
- fun toggleDeviceLabel() {
- val newValue = !currentTheme.value.showDeviceLabel
- log(TAG, INFO) { "toggleDeviceLabel(showDeviceLabel=$newValue)" }
- currentTheme.value = currentTheme.value.copy(showDeviceLabel = newValue)
- }
-
fun setShowDeviceLabel(show: Boolean) {
log(TAG, INFO) { "setShowDeviceLabel(show=$show)" }
currentTheme.value = currentTheme.value.copy(showDeviceLabel = show)
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 ad10a6b0..1cf51e35 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
@@ -18,6 +18,7 @@ class WidgetManager @Inject constructor(
suspend fun refreshWidgets() {
log(TAG, VERBOSE) { "refreshWidgets()" }
BatteryGlanceWidget().updateAll(context)
+ AncGlanceWidget().updateAll(context)
}
companion object {
diff --git a/app/src/main/res/drawable/ic_anc_adaptive.xml b/app/src/main/res/drawable/ic_anc_adaptive.xml
new file mode 100644
index 00000000..3cd32bce
--- /dev/null
+++ b/app/src/main/res/drawable/ic_anc_adaptive.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_anc_off.xml b/app/src/main/res/drawable/ic_anc_off.xml
new file mode 100644
index 00000000..3f808341
--- /dev/null
+++ b/app/src/main/res/drawable/ic_anc_off.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_anc_on.xml b/app/src/main/res/drawable/ic_anc_on.xml
new file mode 100644
index 00000000..86c4cd56
--- /dev/null
+++ b/app/src/main/res/drawable/ic_anc_on.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_anc_transparency.xml b/app/src/main/res/drawable/ic_anc_transparency.xml
new file mode 100644
index 00000000..f7011b80
--- /dev/null
+++ b/app/src/main/res/drawable/ic_anc_transparency.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/anc_widget_preview_layout.xml b/app/src/main/res/layout/anc_widget_preview_layout.xml
new file mode 100644
index 00000000..34ca5483
--- /dev/null
+++ b/app/src/main/res/layout/anc_widget_preview_layout.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0e8e4fdd..d624b230 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -173,6 +173,7 @@
Background transparency
Show device name
Reset to defaults
+ You can resize the widget after placing it on your hoem screen.
Custom
Material You
Dark
diff --git a/app/src/main/res/xml/anc_widget_info.xml b/app/src/main/res/xml/anc_widget_info.xml
new file mode 100644
index 00000000..68f0373a
--- /dev/null
+++ b/app/src/main/res/xml/anc_widget_info.xml
@@ -0,0 +1,16 @@
+
+