From 46bb8c98dee8d6ec8e02e56869e12254ca31ca38 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 19 Feb 2026 18:35:09 +0100 Subject: [PATCH] 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. --- .../common/flow/FlowCombineExtensions.kt | 21 + .../ui/widget/WidgetConfigurationActivity.kt | 372 +++++++++++++++++- .../ui/widget/WidgetConfigurationViewModel.kt | 95 ++++- .../capod/main/ui/widget/WidgetProvider.kt | 178 ++++++++- .../capod/main/ui/widget/WidgetTheme.kt | 95 +++++ .../res/drawable/ic_baseline_check_24.xml | 10 + .../res/drawable/widget_color_swatch_bg.xml | 8 + .../widget_color_swatch_selected_ring.xml | 7 + .../main/res/drawable/widget_preview_bg.xml | 6 + .../drawable/widget_preview_checkerboard.xml | 6 + .../res/layout/widget_color_swatch_item.xml | 30 ++ .../main/res/layout/widget_config_preview.xml | 100 +++++ .../layout/widget_configuration_activity.xml | 284 +++++++++++-- .../widget_configuration_profile_item.xml | 6 +- app/src/main/res/values/strings.xml | 14 + 15 files changed, 1171 insertions(+), 61 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/widget/WidgetTheme.kt create mode 100644 app/src/main/res/drawable/ic_baseline_check_24.xml create mode 100644 app/src/main/res/drawable/widget_color_swatch_bg.xml create mode 100644 app/src/main/res/drawable/widget_color_swatch_selected_ring.xml create mode 100644 app/src/main/res/drawable/widget_preview_bg.xml create mode 100644 app/src/main/res/drawable/widget_preview_checkerboard.xml create mode 100644 app/src/main/res/layout/widget_color_swatch_item.xml create mode 100644 app/src/main/res/layout/widget_config_preview.xml diff --git a/app/src/main/java/eu/darken/capod/common/flow/FlowCombineExtensions.kt b/app/src/main/java/eu/darken/capod/common/flow/FlowCombineExtensions.kt index 59745f9b..b93286be 100644 --- a/app/src/main/java/eu/darken/capod/common/flow/FlowCombineExtensions.kt +++ b/app/src/main/java/eu/darken/capod/common/flow/FlowCombineExtensions.kt @@ -36,6 +36,27 @@ inline fun combine( ) } +@Suppress("UNCHECKED_CAST", "LongParameterList") +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + crossinline transform: suspend (T1, T2, T3, T4) -> R +): Flow = 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 combine( flow: Flow, diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt index 70d7ff71..e45604cb 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationActivity.kt @@ -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(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(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(id) ?: continue + tv.setTextColor(fgColor ?: defaultTextColor) + } + + for (id in iconViews) { + val iv = ui.previewCard.findViewById(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(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(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(R.id.swatch_selected_ring)?.isVisible = isSelected + itemView.findViewById(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 + ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt index e251a23f..4fd29bab 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt @@ -1,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(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 = 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") } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProvider.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProvider.kt index d290bc23..65b1d51e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProvider.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProvider.kt @@ -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, + iconViewIds: List, + hasDeviceLabel: Boolean, + ) { + // Always explicitly set background color to ensure previous custom colors are overwritten + val bgColor = theme.backgroundColor + if (bgColor != null) { + val colorWithAlpha = WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha) + views.setInt(R.id.widget_root, "setBackgroundColor", colorWithAlpha) + } else { + // Reset to theme default — resolve ?android:attr/colorBackground + val defaultBg = resolveThemeColor(context, android.R.attr.colorBackground) + views.setInt(R.id.widget_root, "setBackgroundColor", defaultBg) + } + + // Always explicitly set text/icon colors + val fgColor = theme.foregroundColor + if (fgColor != null) { + for (textViewId in textViewIds) { + views.setTextColor(textViewId, fgColor) + } + for (iconViewId in iconViewIds) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + views.setColorStateList(iconViewId, "setImageTintList", ColorStateList.valueOf(fgColor)) + } else { + views.setInt(iconViewId, "setColorFilter", fgColor) + } + } + } else { + // Reset to theme defaults + val defaultTextColor = resolveThemeColor(context, android.R.attr.textColorPrimary) + val defaultIconColor = resolveThemeColor(context, android.R.attr.colorAccent) + for (textViewId in textViewIds) { + views.setTextColor(textViewId, defaultTextColor) + } + for (iconViewId in iconViewIds) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + views.setColorStateList(iconViewId, "setImageTintList", ColorStateList.valueOf(defaultIconColor)) + } else { + views.setInt(iconViewId, "setColorFilter", defaultIconColor) + } + } + } + + if (hasDeviceLabel) { + views.setViewVisibility( + R.id.headphones_label, + if (theme.showDeviceLabel) View.VISIBLE else View.GONE + ) + } + } + + private fun resolveThemeColor(context: Context, attr: Int): Int { + val themedContext = ContextThemeWrapper(context, com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight) + val typedArray = themedContext.theme.obtainStyledAttributes(intArrayOf(attr)) + val color = typedArray.getColor(0, android.graphics.Color.BLACK) + typedArray.recycle() + return color + } + private suspend fun createUpgradeRequiredLayout( context: Context, - widgetId: Int + 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) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetTheme.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetTheme.kt new file mode 100644 index 00000000..fbd334e1 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetTheme.kt @@ -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) + } +} diff --git a/app/src/main/res/drawable/ic_baseline_check_24.xml b/app/src/main/res/drawable/ic_baseline_check_24.xml new file mode 100644 index 00000000..e2d5ad54 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_check_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/widget_color_swatch_bg.xml b/app/src/main/res/drawable/widget_color_swatch_bg.xml new file mode 100644 index 00000000..1ecb2240 --- /dev/null +++ b/app/src/main/res/drawable/widget_color_swatch_bg.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_color_swatch_selected_ring.xml b/app/src/main/res/drawable/widget_color_swatch_selected_ring.xml new file mode 100644 index 00000000..5f40daa9 --- /dev/null +++ b/app/src/main/res/drawable/widget_color_swatch_selected_ring.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/widget_preview_bg.xml b/app/src/main/res/drawable/widget_preview_bg.xml new file mode 100644 index 00000000..e4bec00d --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_preview_checkerboard.xml b/app/src/main/res/drawable/widget_preview_checkerboard.xml new file mode 100644 index 00000000..1f3cfc6e --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_checkerboard.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/widget_color_swatch_item.xml b/app/src/main/res/layout/widget_color_swatch_item.xml new file mode 100644 index 00000000..fa2531eb --- /dev/null +++ b/app/src/main/res/layout/widget_color_swatch_item.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_config_preview.xml b/app/src/main/res/layout/widget_config_preview.xml new file mode 100644 index 00000000..01755582 --- /dev/null +++ b/app/src/main/res/layout/widget_config_preview.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_configuration_activity.xml b/app/src/main/res/layout/widget_configuration_activity.xml index 63fd8e51..698577e5 100644 --- a/app/src/main/res/layout/widget_configuration_activity.xml +++ b/app/src/main/res/layout/widget_configuration_activity.xml @@ -6,49 +6,267 @@ android:layout_height="match_parent" android:orientation="vertical"> - + android:fillViewport="true"> - + android:layout_marginTop="24dp" + android:orientation="vertical" + android:paddingBottom="16dp"> - + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -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"> - \ No newline at end of file + diff --git a/app/src/main/res/layout/widget_configuration_profile_item.xml b/app/src/main/res/layout/widget_configuration_profile_item.xml index 700b4290..51c7e85c 100644 --- a/app/src/main/res/layout/widget_configuration_profile_item.xml +++ b/app/src/main/res/layout/widget_configuration_profile_item.xml @@ -2,11 +2,11 @@ + app:cardCornerRadius="12dp"> - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 59e8f342..b917b02c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,6 +122,20 @@ This feature requires CAPod Pro. No data + Appearance + Presets + Background color + Text & icon color + Background transparency + Show device name + Reset to defaults + Custom + Material You + Dark + Light + Blue + Green + Red Indirect data delivery Use an alternative method to receive BLE data from the system (broadcast instead of callback).