feat(widget): Add theme customization to widget configuration

Add color presets, custom color picker, transparency slider, and device
label toggle to the widget configuration screen. Widget preview updates
in real-time. Use profile label as device name in both preview and
actual widget.
This commit is contained in:
darken
2026-02-20 12:22:55 +01:00
committed by Matthias Urhahn
parent b3ead17b12
commit 46bb8c98de
15 changed files with 1171 additions and 61 deletions
@@ -36,6 +36,27 @@ inline fun <T1, T2, T3, R> combine(
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
crossinline transform: suspend (T1, T2, T3, T4) -> R
): Flow<R> = kotlinx.coroutines.flow.combine(
flow,
flow2,
flow3,
flow4,
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, R> combine(
flow: Flow<T1>,
@@ -3,10 +3,32 @@ package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.Shader
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.text.Editable
import android.text.InputFilter
import android.text.TextWatcher
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.GridLayout
import android.widget.ImageView
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
import androidx.core.graphics.toColorInt
import androidx.core.view.isVisible
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
@@ -30,14 +52,15 @@ class WidgetConfigurationActivity : Activity2() {
private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
private var isUpdatingHexFromCode = false
private val checkerboardDrawable: BitmapDrawable by lazy { createCheckerboardDrawable() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Set result to CANCELED in case user backs out
setResult(RESULT_CANCELED)
// Get widget ID from intent
widgetId = intent.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
@@ -45,7 +68,6 @@ class WidgetConfigurationActivity : Activity2() {
log(TAG) { "onCreate(widgetId=$widgetId)" }
// If widget ID is invalid, finish
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
log(TAG) { "Invalid widget ID, finishing" }
finish()
@@ -61,6 +83,15 @@ class WidgetConfigurationActivity : Activity2() {
ui.profilesRecycler.adapter = profileAdapter
setupPreviewContainer()
setupPresetChips()
setupColorSwatches(ui.bgColorGrid, isBg = true)
setupColorSwatches(ui.fgColorGrid, isBg = false)
setupHexInput()
setupTransparencySlider()
setupShowDeviceLabelSwitch()
setupResetButton()
ui.cancelButton.setOnClickListener {
log(TAG) { "Cancel clicked" }
finish()
@@ -95,6 +126,310 @@ class WidgetConfigurationActivity : Activity2() {
ui.confirmButton.text = getString(R.string.general_upgrade_action)
ui.confirmButton.isEnabled = true
}
updatePresetChipSelection(state.activePreset)
updateCustomSectionsVisibility(state.isCustomMode)
updateSwatchSelection(ui.bgColorGrid, state.theme.backgroundColor)
updateSwatchSelection(ui.fgColorGrid, state.theme.foregroundColor)
updateHexInputs(state.theme)
// Disable transparency slider when using Material You (no custom bg to apply alpha to)
val hasCustomBg = state.theme.backgroundColor != null
ui.transparencySlider.isEnabled = hasCustomBg
ui.transparencyLabel.alpha = if (hasCustomBg) 1.0f else 0.5f
val transparencyPercent = ((255 - state.theme.backgroundAlpha) / 255f * 100f)
val displayPercent = (transparencyPercent / 5f).toInt() * 5
if (!ui.transparencySlider.isPressed) {
ui.transparencySlider.value = displayPercent.toFloat()
}
ui.transparencyLabel.text = getString(
R.string.widget_config_transparency_label
) + if (hasCustomBg && displayPercent > 0) " ($displayPercent%)" else ""
ui.showDeviceLabelSwitch.isChecked = state.theme.showDeviceLabel
val selectedProfileLabel = state.profiles.firstOrNull { it.id == state.selectedProfile }?.label
val deviceLabel = ui.previewCard.findViewById<android.widget.TextView>(R.id.preview_device_label)
deviceLabel?.text = selectedProfileLabel ?: ""
updatePreview(state.theme)
}
}
private val widgetThemeContext: Context by lazy {
ContextThemeWrapper(this, com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight)
}
private fun resolveWidgetThemeColor(attr: Int): Int {
val typedArray = widgetThemeContext.theme.obtainStyledAttributes(intArrayOf(attr))
val color = typedArray.getColor(0, Color.BLACK)
typedArray.recycle()
return color
}
private fun setupPreviewContainer() {
// Initial background set from XML, updated dynamically in updatePreview
}
private fun updatePreview(theme: WidgetTheme) {
val previewRoot = ui.previewCard.findViewById<View>(R.id.preview_widget_root)
// Background color applied to the inner view, matching the real widget
val bgColor = theme.backgroundColor
if (bgColor != null) {
previewRoot.setBackgroundColor(WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha))
} else {
previewRoot.setBackgroundColor(resolveWidgetThemeColor(android.R.attr.colorBackground))
}
// Show checkerboard behind preview only when there's actual transparency to visualize
val hasTransparency = bgColor != null && theme.backgroundAlpha < 255
ui.previewContainer.background = if (hasTransparency) {
checkerboardDrawable
} else {
AppCompatResources.getDrawable(this, R.drawable.widget_preview_checkerboard)
}
// Foreground (text + icon colors)
val fgColor = theme.foregroundColor
val defaultTextColor = resolveWidgetThemeColor(android.R.attr.textColorPrimary)
val defaultIconColor = resolveWidgetThemeColor(android.R.attr.colorAccent)
val textViews = listOf(
R.id.preview_left_label,
R.id.preview_right_label,
R.id.preview_case_label,
R.id.preview_device_label,
)
val iconViews = listOf(
R.id.preview_left_icon,
R.id.preview_right_icon,
R.id.preview_case_icon,
)
for (id in textViews) {
val tv = ui.previewCard.findViewById<android.widget.TextView>(id) ?: continue
tv.setTextColor(fgColor ?: defaultTextColor)
}
for (id in iconViews) {
val iv = ui.previewCard.findViewById<ImageView>(id) ?: continue
if (fgColor != null) {
iv.setColorFilter(fgColor, PorterDuff.Mode.SRC_IN)
} else {
iv.setColorFilter(defaultIconColor, PorterDuff.Mode.SRC_IN)
}
}
// Device label visibility
val deviceLabel = ui.previewCard.findViewById<View>(R.id.preview_device_label)
deviceLabel?.isVisible = theme.showDeviceLabel
}
private fun createCheckerboardDrawable(): BitmapDrawable {
val cellSize = (8 * resources.displayMetrics.density).toInt()
val bitmap = createBitmap(cellSize * 2, cellSize * 2)
val canvas = Canvas(bitmap)
val paint = Paint()
// Light squares
paint.color = 0xFFE8E8E8.toInt()
canvas.drawRect(0f, 0f, (cellSize * 2).toFloat(), (cellSize * 2).toFloat(), paint)
// Dark squares
paint.color = 0xFFD0D0D0.toInt()
canvas.drawRect(0f, 0f, cellSize.toFloat(), cellSize.toFloat(), paint)
canvas.drawRect(
cellSize.toFloat(),
cellSize.toFloat(),
(cellSize * 2).toFloat(),
(cellSize * 2).toFloat(),
paint
)
return bitmap.toDrawable(resources).apply {
tileModeX = Shader.TileMode.REPEAT
tileModeY = Shader.TileMode.REPEAT
}
}
private fun setupPresetChips() {
val presetNames = mapOf(
WidgetTheme.Preset.MATERIAL_YOU to getString(R.string.widget_config_preset_material_you),
WidgetTheme.Preset.CLASSIC_DARK to getString(R.string.widget_config_preset_dark),
WidgetTheme.Preset.CLASSIC_LIGHT to getString(R.string.widget_config_preset_light),
WidgetTheme.Preset.BLUE to getString(R.string.widget_config_preset_blue),
WidgetTheme.Preset.GREEN to getString(R.string.widget_config_preset_green),
WidgetTheme.Preset.RED to getString(R.string.widget_config_preset_red),
)
for (preset in WidgetTheme.Preset.entries) {
val chip = Chip(this).apply {
text = presetNames[preset] ?: preset.name
isCheckable = true
tag = preset
setOnClickListener { vm.selectPreset(preset) }
}
ui.presetChipGroup.addView(chip)
}
// Custom chip
val customChip = Chip(this).apply {
text = getString(R.string.widget_config_custom_label)
isCheckable = true
tag = CUSTOM_CHIP_TAG
setOnClickListener {
val defaultBg = resolveWidgetThemeColor(android.R.attr.colorBackground)
val defaultFg = resolveWidgetThemeColor(android.R.attr.textColorPrimary)
vm.enterCustomMode(defaultBg, defaultFg)
}
}
ui.presetChipGroup.addView(customChip)
}
private fun updatePresetChipSelection(activePreset: WidgetTheme.Preset?) {
for (i in 0 until ui.presetChipGroup.childCount) {
val chip = ui.presetChipGroup.getChildAt(i) as? Chip ?: continue
chip.isChecked = if (activePreset != null) {
chip.tag == activePreset
} else {
chip.tag == CUSTOM_CHIP_TAG
}
}
}
private fun updateCustomSectionsVisibility(isCustomMode: Boolean) {
val visibility = if (isCustomMode) View.VISIBLE else View.GONE
ui.bgColorLabel.visibility = visibility
ui.bgColorGrid.visibility = visibility
ui.bgHexInputLayout.visibility = visibility
ui.fgColorLabel.visibility = visibility
ui.fgColorGrid.visibility = visibility
ui.fgHexInputLayout.visibility = visibility
}
private fun setupColorSwatches(grid: GridLayout, isBg: Boolean) {
for (color in SWATCH_COLORS) {
val itemView = LayoutInflater.from(this)
.inflate(R.layout.widget_color_swatch_item, grid, false)
val swatchColor = itemView.findViewById<View>(R.id.swatch_color)
val bgDrawable = swatchColor.background?.mutate() as? GradientDrawable ?: continue
bgDrawable.setColor(color)
swatchColor.background = bgDrawable
itemView.setOnClickListener {
ui.bgHexInput.clearFocus()
ui.fgHexInput.clearFocus()
if (isBg) vm.setBackgroundColor(color) else vm.setForegroundColor(color)
}
val params = GridLayout.LayoutParams(
GridLayout.spec(GridLayout.UNDEFINED),
GridLayout.spec(GridLayout.UNDEFINED, 1f),
).apply {
width = GridLayout.LayoutParams.WRAP_CONTENT
height = GridLayout.LayoutParams.WRAP_CONTENT
setGravity(Gravity.CENTER)
}
grid.addView(itemView, params)
}
}
private fun updateSwatchSelection(grid: GridLayout, selectedColor: Int?) {
for (i in 0 until grid.childCount) {
val itemView = grid.getChildAt(i) as? FrameLayout ?: continue
val color = SWATCH_COLORS.getOrNull(i) ?: continue
val isSelected =
selectedColor != null && (selectedColor or 0xFF000000.toInt()) == (color or 0xFF000000.toInt())
itemView.findViewById<View>(R.id.swatch_selected_ring)?.isVisible = isSelected
itemView.findViewById<ImageView>(R.id.swatch_check)?.apply {
isVisible = isSelected
if (isSelected) {
val checkColor = WidgetTheme.bestContrastForeground(color)
setColorFilter(checkColor)
}
}
}
}
private fun setupHexInput() {
val hexFilter = InputFilter { source, _, _, _, _, _ ->
val filtered = source.toString().uppercase().filter { it in "0123456789ABCDEF" }
if (filtered == source.toString()) null else filtered
}
ui.bgHexInput.filters = arrayOf(hexFilter, InputFilter.LengthFilter(6))
ui.fgHexInput.filters = arrayOf(hexFilter, InputFilter.LengthFilter(6))
ui.bgHexInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
if (isUpdatingHexFromCode) return
val hex = s?.toString() ?: return
if (hex.length == 6) {
try {
val color = "#$hex".toColorInt()
vm.setBackgroundColor(color)
} catch (_: IllegalArgumentException) {
}
}
}
})
ui.fgHexInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
if (isUpdatingHexFromCode) return
val hex = s?.toString() ?: return
if (hex.length == 6) {
try {
val color = "#$hex".toColorInt()
vm.setForegroundColor(color)
} catch (_: IllegalArgumentException) {
}
}
}
})
}
private fun updateHexInputs(theme: WidgetTheme) {
isUpdatingHexFromCode = true
try {
val bgHex = theme.backgroundColor?.let { String.format("%06X", 0xFFFFFF and it) } ?: ""
if (ui.bgHexInput.text.toString() != bgHex && !ui.bgHexInput.hasFocus()) {
ui.bgHexInput.setText(bgHex)
}
val fgHex = theme.foregroundColor?.let { String.format("%06X", 0xFFFFFF and it) } ?: ""
if (ui.fgHexInput.text.toString() != fgHex && !ui.fgHexInput.hasFocus()) {
ui.fgHexInput.setText(fgHex)
}
} finally {
isUpdatingHexFromCode = false
}
}
private fun setupTransparencySlider() {
ui.transparencySlider.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
val alpha = 255 - (value / 100f * 255f).toInt()
vm.setBackgroundAlpha(alpha)
}
}
}
private fun setupShowDeviceLabelSwitch() {
ui.showDeviceLabelSwitch.setOnCheckedChangeListener { _, isChecked ->
vm.setShowDeviceLabel(isChecked)
}
}
private fun setupResetButton() {
ui.resetButton.setOnClickListener {
vm.resetToDefaults()
}
}
@@ -113,10 +448,37 @@ class WidgetConfigurationActivity : Activity2() {
)
finish()
}
companion object {
private val TAG = logTag("Widget", "ConfigurationActivity")
private const val CUSTOM_CHIP_TAG = "custom"
private val SWATCH_COLORS = intArrayOf(
0xFFF44336.toInt(), // Red
0xFFE91E63.toInt(), // Pink
0xFF9C27B0.toInt(), // Purple
0xFF673AB7.toInt(), // Deep Purple
0xFF3F51B5.toInt(), // Indigo
0xFF2196F3.toInt(), // Blue
0xFF03A9F4.toInt(), // Light Blue
0xFF00BCD4.toInt(), // Cyan
0xFF009688.toInt(), // Teal
0xFF4CAF50.toInt(), // Green
0xFF8BC34A.toInt(), // Light Green
0xFFCDDC39.toInt(), // Lime
0xFFFFEB3B.toInt(), // Yellow
0xFFFFC107.toInt(), // Amber
0xFFFF9800.toInt(), // Orange
0xFFFF5722.toInt(), // Deep Orange
0xFF795548.toInt(), // Brown
0xFF9E9E9E.toInt(), // Grey
0xFF607D8B.toInt(), // Blue Grey
0xFFFFFFFF.toInt(), // White
0xFF1E1E1E.toInt(), // Near Black
0xFF37474F.toInt(), // Dark Blue Grey
0xFF1B5E20.toInt(), // Dark Green
0xFF0D47A1.toInt(), // Dark Blue
)
}
}
}
@@ -1,8 +1,10 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
@@ -22,8 +24,11 @@ class WidgetConfigurationViewModel @Inject constructor(
private val deviceProfilesRepo: DeviceProfilesRepo,
private val widgetSettings: WidgetSettings,
private val upgradeRepo: UpgradeRepo,
@ApplicationContext private val context: Context,
) : ViewModel3(dispatcherProvider) {
private val appWidgetManager by lazy { AppWidgetManager.getInstance(context) }
val widgetId: Int
get() = savedStateHandle.get<Int>(AppWidgetManager.EXTRA_APPWIDGET_ID) ?: AppWidgetManager.INVALID_APPWIDGET_ID
@@ -37,18 +42,32 @@ class WidgetConfigurationViewModel @Inject constructor(
private val selectedProfile = MutableStateFlow(widgetSettings.getWidgetProfile(widgetId))
val state = combine(
private val initialTheme: WidgetTheme = run {
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetTheme.DEFAULT
WidgetTheme.fromBundle(appWidgetManager.getAppWidgetOptions(widgetId))
}
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialTheme) == null)
private val currentTheme = MutableStateFlow(initialTheme)
val state = eu.darken.capod.common.flow.combine(
selectedProfile,
currentTheme,
forceCustomMode,
deviceProfilesRepo.profiles,
upgradeRepo.upgradeInfo,
) { selected, profiles, upgradeInfo ->
log(TAG) { "loadProfiles()" }
) { selected, theme, forceCustom, profiles, upgradeInfo ->
log(TAG) { "state update: profile=$selected, theme=$theme, forceCustom=$forceCustom" }
val activePreset = if (forceCustom) null else WidgetTheme.matchPreset(theme)
State(
profiles = profiles,
isPro = upgradeInfo.isPro,
selectedProfile = selected,
theme = theme,
activePreset = activePreset,
isCustomMode = activePreset == null,
)
}.asLiveData2()
@@ -56,13 +75,70 @@ class WidgetConfigurationViewModel @Inject constructor(
val profiles: List<DeviceProfile> = emptyList(),
val selectedProfile: ProfileId? = null,
val isPro: Boolean = false,
val theme: WidgetTheme = WidgetTheme.DEFAULT,
val activePreset: WidgetTheme.Preset? = WidgetTheme.Preset.MATERIAL_YOU,
val isCustomMode: Boolean = false,
) {
val canConfirm: Boolean = selectedProfile != null
}
fun selectProfile(profileId: ProfileId) {
log(TAG) { "selectProfile(profileId=$profileId)" }
selectedProfile.value = profileId
selectedProfile.value = profileId
}
fun selectPreset(preset: WidgetTheme.Preset) {
log(TAG) { "selectPreset(preset=$preset)" }
forceCustomMode.value = false
currentTheme.value = WidgetTheme(
backgroundColor = preset.presetBg,
foregroundColor = preset.presetFg,
backgroundAlpha = currentTheme.value.backgroundAlpha,
showDeviceLabel = currentTheme.value.showDeviceLabel,
)
}
fun enterCustomMode(resolvedBg: Int, resolvedFg: Int) {
log(TAG) { "enterCustomMode(resolvedBg=${String.format("#%06X", 0xFFFFFF and resolvedBg)}, resolvedFg=${String.format("#%06X", 0xFFFFFF and resolvedFg)})" }
forceCustomMode.value = true
// Populate null colors with the currently displayed values so the user has a starting point
val theme = currentTheme.value
currentTheme.value = theme.copy(
backgroundColor = theme.backgroundColor ?: (resolvedBg or 0xFF000000.toInt()),
foregroundColor = theme.foregroundColor ?: (resolvedFg or 0xFF000000.toInt()),
)
}
fun setBackgroundColor(color: Int) {
log(TAG) { "setBackgroundColor(color=${String.format("#%06X", 0xFFFFFF and color)})" }
currentTheme.value = currentTheme.value.copy(backgroundColor = color or 0xFF000000.toInt())
}
fun setForegroundColor(color: Int) {
log(TAG) { "setForegroundColor(color=${String.format("#%06X", 0xFFFFFF and color)})" }
currentTheme.value = currentTheme.value.copy(foregroundColor = color or 0xFF000000.toInt())
}
fun setBackgroundAlpha(alpha: Int) {
log(TAG) { "setBackgroundAlpha(alpha=$alpha)" }
currentTheme.value = currentTheme.value.copy(backgroundAlpha = alpha.coerceIn(0, 255))
}
fun toggleDeviceLabel() {
val newValue = !currentTheme.value.showDeviceLabel
log(TAG) { "toggleDeviceLabel(showDeviceLabel=$newValue)" }
currentTheme.value = currentTheme.value.copy(showDeviceLabel = newValue)
}
fun setShowDeviceLabel(show: Boolean) {
log(TAG) { "setShowDeviceLabel(show=$show)" }
currentTheme.value = currentTheme.value.copy(showDeviceLabel = show)
}
fun resetToDefaults() {
log(TAG) { "resetToDefaults()" }
forceCustomMode.value = false
currentTheme.value = WidgetTheme.DEFAULT
}
fun confirmSelection() {
@@ -71,9 +147,16 @@ class WidgetConfigurationViewModel @Inject constructor(
log(TAG) { "confirmSelection(widgetId=$widgetId, selectedProfile=$selectedProfile)" }
widgetSettings.saveWidgetProfile(widgetId, selectedProfile)
}
// Save theme to AppWidgetOptions bundle
val theme = currentTheme.value
log(TAG) { "confirmSelection: saving theme=$theme" }
val options = appWidgetManager.getAppWidgetOptions(widgetId)
theme.toBundle(options)
appWidgetManager.updateAppWidgetOptions(widgetId, options)
}
companion object {
private val TAG = logTag("Widget", "ConfigurationVM")
}
}
}
@@ -5,10 +5,13 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.RemoteViews
import androidx.annotation.LayoutRes
import androidx.appcompat.view.ContextThemeWrapper
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.coroutine.AppScope
@@ -32,8 +35,10 @@ import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import finish2
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
@@ -49,6 +54,7 @@ class WidgetProvider : AppWidgetProvider() {
@Inject lateinit var podFactory: PodFactory
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject lateinit var widgetSettings: WidgetSettings
@Inject lateinit var deviceProfilesRepo: DeviceProfilesRepo
@AppScope @Inject lateinit var appScope: CoroutineScope
private fun executeAsync(
@@ -136,9 +142,15 @@ class WidgetProvider : AppWidgetProvider() {
log(TAG) { "updateWidget(widgetId=$widgetId, profileId=$profileId options=$options)" }
val device: PodDevice? = profileId?.let { podMonitor.getDeviceForProfile(it) }
val profileLabel: String? = profileId?.let { id ->
deviceProfilesRepo.profiles.first().firstOrNull { it.id == id }?.label
}
val theme = WidgetTheme.fromBundle(widgetManager.getAppWidgetOptions(widgetId))
log(TAG, VERBOSE) { "updateWidget: theme=$theme" }
val layout = when {
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context, widgetId)
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context, widgetId, theme)
device is DualPodDevice -> {
val minWidth = widgetManager.getAppWidgetOptions(widgetId)
.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
@@ -154,19 +166,84 @@ class WidgetProvider : AppWidgetProvider() {
else -> R.layout.widget_pod_dual_wide_layout
}
createDualPodLayout(context, device, layout, widgetId)
createDualPodLayout(context, device, layout, widgetId, theme, profileLabel)
}
device is SinglePodDevice -> createSinglePodLayout(context, device, widgetId)
device is PodDevice -> createUnknownPodLayout(context, device, widgetId)
else -> createNoDeviceLayout(context, profileId != null, widgetId)
device is SinglePodDevice -> createSinglePodLayout(context, device, widgetId, theme, profileLabel)
device is PodDevice -> createUnknownPodLayout(context, device, widgetId, theme)
else -> createNoDeviceLayout(context, profileId != null, widgetId, theme)
}
widgetManager.updateAppWidget(widgetId, layout)
}
private fun applyThemeColors(
context: Context,
views: RemoteViews,
theme: WidgetTheme,
textViewIds: List<Int>,
iconViewIds: List<Int>,
hasDeviceLabel: Boolean,
) {
// Always explicitly set background color to ensure previous custom colors are overwritten
val bgColor = theme.backgroundColor
if (bgColor != null) {
val colorWithAlpha = WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha)
views.setInt(R.id.widget_root, "setBackgroundColor", colorWithAlpha)
} else {
// Reset to theme default — resolve ?android:attr/colorBackground
val defaultBg = resolveThemeColor(context, android.R.attr.colorBackground)
views.setInt(R.id.widget_root, "setBackgroundColor", defaultBg)
}
// Always explicitly set text/icon colors
val fgColor = theme.foregroundColor
if (fgColor != null) {
for (textViewId in textViewIds) {
views.setTextColor(textViewId, fgColor)
}
for (iconViewId in iconViewIds) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
views.setColorStateList(iconViewId, "setImageTintList", ColorStateList.valueOf(fgColor))
} else {
views.setInt(iconViewId, "setColorFilter", fgColor)
}
}
} else {
// Reset to theme defaults
val defaultTextColor = resolveThemeColor(context, android.R.attr.textColorPrimary)
val defaultIconColor = resolveThemeColor(context, android.R.attr.colorAccent)
for (textViewId in textViewIds) {
views.setTextColor(textViewId, defaultTextColor)
}
for (iconViewId in iconViewIds) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
views.setColorStateList(iconViewId, "setImageTintList", ColorStateList.valueOf(defaultIconColor))
} else {
views.setInt(iconViewId, "setColorFilter", defaultIconColor)
}
}
}
if (hasDeviceLabel) {
views.setViewVisibility(
R.id.headphones_label,
if (theme.showDeviceLabel) View.VISIBLE else View.GONE
)
}
}
private fun resolveThemeColor(context: Context, attr: Int): Int {
val themedContext = ContextThemeWrapper(context, com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight)
val typedArray = themedContext.theme.obtainStyledAttributes(intArrayOf(attr))
val color = typedArray.getColor(0, android.graphics.Color.BLACK)
typedArray.recycle()
return color
}
private suspend fun createUpgradeRequiredLayout(
context: Context,
widgetId: Int
widgetId: Int,
theme: WidgetTheme,
) = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createUpgradeRequiredLayout(context=$context)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
@@ -181,12 +258,22 @@ class WidgetProvider : AppWidgetProvider() {
setTextViewText(R.id.primary, context.getString(R.string.upgrade_capod_label))
setTextViewText(R.id.secondary, context.getString(R.string.upgrade_capod_description))
setViewVisibility(R.id.secondary, View.VISIBLE)
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(R.id.primary, R.id.secondary),
iconViewIds = emptyList(),
hasDeviceLabel = false,
)
}
private fun createUnknownPodLayout(
context: Context,
podDevice: PodDevice,
widgetId: Int
widgetId: Int,
theme: WidgetTheme,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createUnknownPodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
@@ -199,12 +286,22 @@ class WidgetProvider : AppWidgetProvider() {
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.primary, context.getString(R.string.pods_unknown_label))
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(R.id.primary),
iconViewIds = emptyList(),
hasDeviceLabel = false,
)
}
private fun createNoDeviceLayout(
context: Context,
hasConfiguredProfile: Boolean = false,
widgetId: Int
widgetId: Int,
theme: WidgetTheme,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context, hasConfiguredProfile=$hasConfiguredProfile)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
@@ -222,15 +319,26 @@ class WidgetProvider : AppWidgetProvider() {
R.string.overview_nomaindevice_label
}
setTextViewText(R.id.primary, context.getString(messageRes))
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(R.id.primary),
iconViewIds = emptyList(),
hasDeviceLabel = false,
)
}
private fun createDualPodLayout(
context: Context,
podDevice: DualPodDevice,
@LayoutRes layout: Int,
widgetId: Int
widgetId: Int,
theme: WidgetTheme,
profileLabel: String?,
): RemoteViews = RemoteViews(context.packageName, layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice), layout=${layout}" }
log(TAG, VERBOSE) { "createDualPodLayout(context=$context, podDevice=$podDevice), layout=${layout}" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
widgetId,
@@ -240,7 +348,7 @@ class WidgetProvider : AppWidgetProvider() {
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
setTextViewText(R.id.headphones_label, profileLabel ?: podDevice.getLabel(context))
// Left
val leftPercent = podDevice.batteryLeftPodPercent
@@ -276,12 +384,37 @@ class WidgetProvider : AppWidgetProvider() {
R.id.pod_right_ear,
if (podDevice is HasEarDetectionDual && podDevice.isRightPodInEar) View.VISIBLE else View.GONE
)
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(
R.id.headphones_label,
R.id.pod_left_label,
R.id.pod_right_label,
R.id.pod_case_label,
),
iconViewIds = listOf(
R.id.pod_left_icon,
R.id.pod_left_charging,
R.id.pod_left_ear,
R.id.pod_case_icon,
R.id.pod_case_charging,
R.id.pod_right_icon,
R.id.pod_right_charging,
R.id.pod_right_ear,
),
hasDeviceLabel = true,
)
}
private fun createSinglePodLayout(
context: Context,
podDevice: SinglePodDevice,
widgetId: Int
widgetId: Int,
theme: WidgetTheme,
profileLabel: String?,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_single_layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
@@ -294,7 +427,7 @@ class WidgetProvider : AppWidgetProvider() {
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
val headsetPercent = podDevice.batteryHeadsetPercent
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
setTextViewText(R.id.headphones_label, profileLabel ?: podDevice.getLabel(context))
setImageViewResource(R.id.headphones_icon, podDevice.iconRes)
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
@@ -308,6 +441,23 @@ class WidgetProvider : AppWidgetProvider() {
R.id.headphones_charging,
if (podDevice is HasChargeDetectionDual && podDevice.isHeadsetBeingCharged) View.VISIBLE else View.GONE
)
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(
R.id.headphones_label,
R.id.headphones_battery_label,
),
iconViewIds = listOf(
R.id.headphones_icon,
R.id.headphones_battery_icon,
R.id.headphones_charging,
R.id.headphones_worn,
),
hasDeviceLabel = true,
)
}
companion object {
@@ -321,4 +471,4 @@ class WidgetProvider : AppWidgetProvider() {
context.sendBroadcast(intent)
}
}
}
}
@@ -0,0 +1,95 @@
package eu.darken.capod.main.ui.widget
import android.graphics.Color
import android.os.Bundle
import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils
data class WidgetTheme(
@ColorInt val backgroundColor: Int? = null,
@ColorInt val foregroundColor: Int? = null,
val backgroundAlpha: Int = 255,
val showDeviceLabel: Boolean = true,
) {
enum class Preset(
@ColorInt val presetBg: Int?,
@ColorInt val presetFg: Int?,
) {
MATERIAL_YOU(null, null),
CLASSIC_DARK(0xFF1E1E1E.toInt(), 0xFFFFFFFF.toInt()),
CLASSIC_LIGHT(0xFFFFFFFF.toInt(), 0xFF1E1E1E.toInt()),
BLUE(0xFF1565C0.toInt(), 0xFFFFFFFF.toInt()),
GREEN(0xFF2E7D32.toInt(), 0xFFFFFFFF.toInt()),
RED(0xFFC62828.toInt(), 0xFFFFFFFF.toInt()),
}
companion object {
private const val KEY_THEME_MODE = "capod_theme_mode"
private const val KEY_CUSTOM_BG = "capod_custom_bg"
private const val KEY_CUSTOM_FG = "capod_custom_fg"
private const val KEY_BG_ALPHA = "capod_bg_alpha"
private const val KEY_SHOW_LABEL = "capod_show_label"
private const val MODE_MATERIAL_YOU = "material_you"
private const val MODE_CUSTOM = "custom"
val DEFAULT = WidgetTheme()
fun fromBundle(bundle: Bundle): WidgetTheme {
val mode = bundle.getString(KEY_THEME_MODE, MODE_MATERIAL_YOU)
val showLabel = bundle.getBoolean(KEY_SHOW_LABEL, true)
val alpha = bundle.getInt(KEY_BG_ALPHA, 255).coerceIn(0, 255)
return if (mode == MODE_CUSTOM) {
WidgetTheme(
backgroundColor = if (bundle.containsKey(KEY_CUSTOM_BG)) bundle.getInt(KEY_CUSTOM_BG) else null,
foregroundColor = if (bundle.containsKey(KEY_CUSTOM_FG)) bundle.getInt(KEY_CUSTOM_FG) else null,
backgroundAlpha = alpha,
showDeviceLabel = showLabel,
)
} else {
WidgetTheme(
backgroundAlpha = alpha,
showDeviceLabel = showLabel,
)
}
}
fun applyAlpha(@ColorInt color: Int, alpha: Int): Int {
val clampedAlpha = alpha.coerceIn(0, 255)
return Color.argb(clampedAlpha, Color.red(color), Color.green(color), Color.blue(color))
}
fun bestContrastForeground(@ColorInt bgColor: Int): Int {
val whiteContrast = ColorUtils.calculateContrast(Color.WHITE, bgColor)
val blackContrast = ColorUtils.calculateContrast(Color.BLACK, bgColor)
return if (whiteContrast >= blackContrast) Color.WHITE else Color.BLACK
}
fun matchPreset(theme: WidgetTheme): Preset? = Preset.entries.firstOrNull { preset ->
preset.presetBg == theme.backgroundColor && preset.presetFg == theme.foregroundColor && theme.backgroundAlpha == 255
}
}
fun toBundle(bundle: Bundle) {
if (backgroundColor == null && foregroundColor == null) {
bundle.putString(KEY_THEME_MODE, MODE_MATERIAL_YOU)
bundle.remove(KEY_CUSTOM_BG)
bundle.remove(KEY_CUSTOM_FG)
} else {
bundle.putString(KEY_THEME_MODE, MODE_CUSTOM)
if (backgroundColor != null) {
bundle.putInt(KEY_CUSTOM_BG, backgroundColor)
} else {
bundle.remove(KEY_CUSTOM_BG)
}
if (foregroundColor != null) {
bundle.putInt(KEY_CUSTOM_FG, foregroundColor)
} else {
bundle.remove(KEY_CUSTOM_FG)
}
}
bundle.putInt(KEY_BG_ALPHA, backgroundAlpha.coerceIn(0, 255))
bundle.putBoolean(KEY_SHOW_LABEL, showDeviceLabel)
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFFFFF" />
<stroke
android:width="1dp"
android:color="#20000000" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<stroke
android:width="3dp"
android:color="?colorPrimary" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="@android:color/transparent" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="?colorSurfaceVariant" />
</shape>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_margin="4dp">
<View
android:id="@+id/swatch_color"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="center"
android:background="@drawable/widget_color_swatch_bg" />
<View
android:id="@+id/swatch_selected_ring"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="center"
android:background="@drawable/widget_color_swatch_selected_ring"
android:visibility="gone" />
<ImageView
android:id="@+id/swatch_check"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_gravity="center"
android:src="@drawable/ic_baseline_check_24"
android:visibility="gone" />
</FrameLayout>
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/preview_widget_root"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="160dp"
android:gravity="center"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/preview_left_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/devic_airpods_gen1_left"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/preview_left_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="4dp"
android:text="80%"
tools:ignore="HardcodedText" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/preview_right_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/devic_airpods_gen1_right"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/preview_right_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="4dp"
android:text="95%"
tools:ignore="HardcodedText" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/preview_case_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/devic_airpods_gen1_case"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/preview_case_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="4dp"
android:text="100%"
tools:ignore="HardcodedText" />
</LinearLayout>
<TextView
android:id="@+id/preview_device_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
tools:text="AirPods Pro"
android:textColor="?android:attr/textColorPrimary"
android:textSize="12sp"
android:textStyle="bold"
tools:ignore="HardcodedText" />
</LinearLayout>
@@ -6,49 +6,267 @@
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/content_container"
<androidx.core.widget.NestedScrollView
android:id="@+id/scroll_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="32dp"
android:layout_weight="1"
android:orientation="vertical">
android:fillViewport="true">
<com.google.android.material.textview.MaterialTextView
<LinearLayout
android:id="@+id/content_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_configuration_title"
android:textAppearance="@style/TextAppearance.Material3.TitleLarge" />
android:layout_marginTop="24dp"
android:orientation="vertical"
android:paddingBottom="16dp">
<com.google.android.material.textview.MaterialTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:text="@string/widget_configuration_description"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<!-- Profile selection -->
<com.google.android.material.textview.MaterialTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:text="@string/widget_configuration_title"
android:textAppearance="@style/TextAppearance.Material3.TitleLarge" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/profiles_recycler"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/widget_configuration_profile_item" />
<com.google.android.material.textview.MaterialTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="12dp"
android:text="@string/widget_configuration_description"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?colorOnSurfaceVariant" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/profiles_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:nestedScrollingEnabled="false"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/widget_configuration_profile_item" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginVertical="16dp" />
<!-- Appearance section header + reset button -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/appearance_header"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/widget_config_appearance_label"
android:textAppearance="@style/TextAppearance.Material3.TitleLarge" />
<com.google.android.material.button.MaterialButton
android:id="@+id/reset_button"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_config_reset_label" />
</LinearLayout>
<!-- Live preview with container -->
<FrameLayout
android:id="@+id/preview_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="12dp"
android:background="@drawable/widget_preview_checkerboard"
android:padding="24dp">
<FrameLayout
android:id="@+id/preview_card"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/widget_preview_bg"
android:clipToOutline="true">
<include layout="@layout/widget_config_preview" />
</FrameLayout>
</FrameLayout>
<!-- Transparency slider — directly below preview for live feedback -->
<com.google.android.material.textview.MaterialTextView
android:id="@+id/transparency_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="16dp"
android:text="@string/widget_config_transparency_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?colorOnSurfaceVariant" />
<com.google.android.material.slider.Slider
android:id="@+id/transparency_slider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:value="0"
android:valueFrom="0"
android:valueTo="100"
android:stepSize="5" />
<!-- Show device name toggle -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:gravity="center_vertical"
android:minHeight="48dp"
android:orientation="horizontal">
<com.google.android.material.textview.MaterialTextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/widget_config_show_device_label"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/show_device_label_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true" />
</LinearLayout>
<!-- Preset chips -->
<com.google.android.material.textview.MaterialTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="16dp"
android:text="@string/widget_config_preset_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?colorOnSurfaceVariant" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/preset_chip_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="8dp"
app:selectionRequired="true"
app:singleSelection="true" />
<!-- Background color (custom mode only) -->
<com.google.android.material.textview.MaterialTextView
android:id="@+id/bg_color_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="16dp"
android:text="@string/widget_config_background_color_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?colorOnSurfaceVariant"
android:visibility="gone" />
<GridLayout
android:id="@+id/bg_color_grid"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="8dp"
android:columnCount="8"
android:visibility="gone" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/bg_hex_input_layout"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="160dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="8dp"
android:hint="#"
android:visibility="gone">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/bg_hex_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapCharacters|textNoSuggestions"
android:maxLength="7"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Foreground color (custom mode only) -->
<com.google.android.material.textview.MaterialTextView
android:id="@+id/fg_color_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="16dp"
android:text="@string/widget_config_foreground_color_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?colorOnSurfaceVariant"
android:visibility="gone" />
<GridLayout
android:id="@+id/fg_color_grid"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="8dp"
android:columnCount="8"
android:visibility="gone" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/fg_hex_input_layout"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="160dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="8dp"
android:hint="#"
android:visibility="gone">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/fg_hex_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapCharacters|textNoSuggestions"
android:maxLength="7"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pro_required_caption"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="32dp"
android:text="@string/common_feature_requires_pro_msg"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="12dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorOnSurfaceVariant"
android:text="@string/common_feature_requires_pro_msg"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?colorError"
android:visibility="gone"
tools:visibility="visible" />
@@ -56,15 +274,15 @@
android:id="@+id/buttons_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="32dp"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="24dp"
android:gravity="end"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/cancel_button"
style="@style/Widget.Material3.Button.TextButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
@@ -79,4 +297,4 @@
</LinearLayout>
</LinearLayout>
</LinearLayout>
@@ -2,11 +2,11 @@
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.Material3.CardView.Outlined"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="4dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
@@ -57,4 +57,4 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</com.google.android.material.card.MaterialCardView>
+14
View File
@@ -122,6 +122,20 @@
<string name="common_feature_requires_pro_msg">This feature requires CAPod Pro.</string>
<string name="widget_no_data_label">No data</string>
<string name="widget_config_appearance_label">Appearance</string>
<string name="widget_config_preset_label">Presets</string>
<string name="widget_config_background_color_label">Background color</string>
<string name="widget_config_foreground_color_label">Text &amp; icon color</string>
<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_custom_label">Custom</string>
<string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Dark</string>
<string name="widget_config_preset_light">Light</string>
<string name="widget_config_preset_blue">Blue</string>
<string name="widget_config_preset_green">Green</string>
<string name="widget_config_preset_red">Red</string>
<string name="settings_compat_indirectcallback_title">Indirect data delivery</string>
<string name="settings_compat_indirectcallback_summary">Use an alternative method to receive BLE data from the system (broadcast instead of callback).</string>