feat(widget): Add ANC control widget

New home-screen widget that toggles AirPods ANC modes (Off / ANC / Transparency / Adaptive) directly, with six adaptive layouts that pick based on widget size (QUAD_CORNERS, ROW_ICONS, COLUMN_ICONS, GRID_2X2, ROW, COLUMN). Consolidated battery+ANC configuration into a single ViewModel that detects widget type from AppWidgetManager. ACTIVE state uses Material3 secondaryContainer/onSecondaryContainer for guaranteed contrast. Includes live config preview, preview subtitle, stale-selection guard, device-label toggle, and aligned icons with the app's AncModeSelector.
This commit is contained in:
darken
2026-04-17 20:10:48 +02:00
committed by Matthias Urhahn
parent bba9155573
commit 86b685f28b
19 changed files with 1434 additions and 27 deletions
+11
View File
@@ -101,6 +101,17 @@
android:resource="@xml/battery_widget_info" />
</receiver>
<receiver
android:name=".main.ui.widget.AncWidgetProvider"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/anc_widget_info" />
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -0,0 +1,170 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.annotation.Keep
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.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfilesRepo
class AncGlanceWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Exact
@Keep
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AncWidgetEntryPoint {
fun deviceMonitor(): DeviceMonitor
fun upgradeRepo(): UpgradeRepo
fun widgetSettings(): WidgetSettings
fun deviceProfilesRepo(): DeviceProfilesRepo
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
val ep: AncWidgetEntryPoint
val appWidgetId: Int
val initialIsPro: Boolean
val initialProfileId: String?
val cachedDevice: PodDevice?
try {
ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java)
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
initialIsPro = ep.upgradeRepo().isPro()
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
provideContent {
GlanceAncWidgetContent(
state = 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),
),
context = context,
appWidgetId = appWidgetId(context, id),
)
}
return
}
provideContent {
val devices by ep.deviceMonitor().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 heightDp = LocalSize.current.height
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.profileId == profileId }
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
val profileLabel = profileId?.let { pid ->
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")
}
}
@@ -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<AapSetting.AncMode.Value>(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<String>("anc_mode")
val WIDGET_ID_KEY = ActionParameters.Key<Int>("widget_id")
private val TAG = logTag("Widget", "ANC", "Callback")
}
}
@@ -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")
}
}
@@ -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<ModeItem>,
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> = 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
}
@@ -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
}
@@ -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<ModeItem>,
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,
)
}
}
}
@@ -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<ModeItem>,
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<ModeItem>,
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<ModeItem>,
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<ModeItem>,
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<ModeItem>,
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<ModeItem>,
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<AncModeActionCallback>(
parameters = androidx.glance.action.actionParametersOf(
AncModeActionCallback.ANC_MODE_KEY to item.mode.name,
AncModeActionCallback.WIDGET_ID_KEY to appWidgetId,
)
)
@@ -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()
}
}
@@ -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)
}
}
}
@@ -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<Int>(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)
@@ -18,6 +18,7 @@ class WidgetManager @Inject constructor(
suspend fun refreshWidgets() {
log(TAG, VERBOSE) { "refreshWidgets()" }
BatteryGlanceWidget().updateAll(context)
AncGlanceWidget().updateAll(context)
}
companion object {
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,9l1.25,-2.75L23,5l-2.75,-1.25L19,1l-1.25,2.75L15,5l2.75,1.25zM19,15l-1.25,2.75L15,19l2.75,1.25L19,23l1.25,-2.75L23,19l-2.75,-1.25zM11.5,9.5L9,4 6.5,9.5 1,12l5.5,2.5L9,20l2.5,-5.5L17,12z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM17,13H7v-2h10V13z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,1c-4.97,0 -9,4.03 -9,9v7c0,1.66 1.34,3 3,3h3v-8H5v-2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7v2h-4v8h3c1.66,0 3,-1.34 3,-3v-7c0,-4.97 -4.03,-9 -9,-9z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17,20c-0.29,0 -0.56,-0.06 -0.76,-0.15 -0.71,-0.37 -1.21,-0.88 -1.71,-2.38 -0.51,-1.56 -1.47,-2.29 -2.39,-3 -0.79,-0.61 -1.61,-1.24 -2.32,-2.53C9.29,10.98 9,9.93 9,9c0,-2.8 2.2,-5 5,-5s5,2.2 5,5h2c0,-3.93 -3.07,-7 -7,-7S7,5.07 7,9c0,1.26 0.38,2.65 1.07,3.9 0.91,1.65 1.98,2.48 2.85,3.15 0.81,0.62 1.39,1.07 1.71,2.05 0.6,1.82 1.37,2.84 2.73,3.55 0.51,0.23 1.07,0.35 1.64,0.35 2.21,0 4,-1.79 4,-4h-2c0,1.1 -0.9,2 -2,2zM7.64,2.64L6.22,1.22C4.23,3.21 3,5.96 3,9s1.23,5.79 3.22,7.78l1.41,-1.41C6.01,13.74 5,11.49 5,9s1.01,-4.74 2.64,-6.36zM11.5,9c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5 -1.12,-2.5 -2.5,-2.5 -2.5,1.12 -2.5,2.5z" />
</vector>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/PodWidget.Container"
android:gravity="center"
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_margin="4dp"
android:padding="8dp"
android:src="@drawable/ic_anc_off"
tools:ignore="ContentDescription" />
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_margin="4dp"
android:padding="8dp"
android:src="@drawable/ic_anc_on"
tools:ignore="ContentDescription" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_margin="4dp"
android:padding="8dp"
android:src="@drawable/ic_anc_transparency"
tools:ignore="ContentDescription" />
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_margin="4dp"
android:padding="8dp"
android:src="@drawable/ic_anc_adaptive"
tools:ignore="ContentDescription" />
</LinearLayout>
</LinearLayout>
+1
View File
@@ -173,6 +173,7 @@
<string name="widget_config_transparency_label">Background transparency</string>
<string name="widget_config_show_device_label">Show device name</string>
<string name="widget_config_reset_label">Reset to defaults</string>
<string name="widget_config_preview_resize_hint">You can resize the widget after placing it on your hoem screen.</string>
<string name="widget_config_custom_label">Custom</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Dark</string>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:minWidth="40dp"
android:minHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:updatePeriodMillis="86400000"
android:description="@string/anc_widget_description"
android:previewLayout="@layout/anc_widget_preview_layout"
android:initialLayout="@layout/widget_loading_layout"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable"
android:configure="eu.darken.capod.main.ui.widget.WidgetConfigurationActivity"
tools:targetApi="s" />