feat(ui): add custom theme color support and share app functionality

Introduce a new custom theme feature that allows users to personalize the application background color using RGB sliders, hex input, or a set of predefined palettes.

Key changes include:
* **Custom Theme Engine**: Added `CustomThemeFragment` and `ThemeHelper` to manage color selection and application. The UI dynamically updates the background color across the `MainActivity` and `PlayerFragment` when preferences change.
* **Android TV Support**: Provided a specialized layout for television devices (`fragment_custom_theme.xml` in `layout-television`) with optimized focus handling for D-pad navigation.
* **Share Feature**: Implemented a "Share App" preference in `SettingsFragment` that triggers a standard Android share intent and displays a thank-you notification upon use.
* **Localization**: Added Ukrainian language support and updated string resources for multiple locales (DE, DA, EL, FR, JA, NL, PL, RU) to include the new theme and share options.
* **Persistence**: Updated `PreferencesHelper` and `Keys` to store theme-related settings, including the enabled state, selected color, and predefined color index.
This commit is contained in:
2026-06-01 19:32:46 +02:00
parent 4429ed4057
commit 181ebd47df
29 changed files with 947 additions and 3 deletions
@@ -0,0 +1,207 @@
package com.michatec.radio
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.SeekBar
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.toColorInt
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textfield.TextInputEditText
import com.michatec.radio.helpers.PreferencesHelper
import com.michatec.radio.helpers.ThemeHelper
class CustomThemeFragment : Fragment() {
private lateinit var colorPreview: View
private lateinit var hexCode: TextInputEditText
private lateinit var seekRed: SeekBar
private lateinit var seekGreen: SeekBar
private lateinit var seekBlue: SeekBar
private lateinit var recyclerView: RecyclerView
private var currentColor: Int = Color.BLACK
private var isUpdatingFromHex = false
private fun applyColor(
color: Int
) {
updateSeekBars(color)
updatePreview(color)
}
private val isAndroidTV: Boolean by lazy {
requireContext().packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_custom_theme, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(activity as? AppCompatActivity)?.supportActionBar?.title = getString(R.string.pref_custom_theme_title)
colorPreview = view.findViewById(R.id.color_preview)
hexCode = view.findViewById(R.id.hex_code)
seekRed = view.findViewById(R.id.seek_red)
seekGreen = view.findViewById(R.id.seek_green)
seekBlue = view.findViewById(R.id.seek_blue)
recyclerView = view.findViewById(R.id.color_recycler_view)
currentColor = PreferencesHelper.loadCustomThemeColor(requireContext())
applyColor(currentColor)
val seekBarListener = object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
val r = seekRed.progress
val g = seekGreen.progress
val b = seekBlue.progress
currentColor = Color.rgb(r, g, b)
updatePreview(currentColor)
PreferencesHelper.saveCustomTheme(currentColor, -1)
(recyclerView.adapter as? ColorAdapter)?.resetSelection()
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
}
seekRed.setOnSeekBarChangeListener(seekBarListener)
seekGreen.setOnSeekBarChangeListener(seekBarListener)
seekBlue.setOnSeekBarChangeListener(seekBarListener)
// Clipboard logic (Non-TV)
if (!isAndroidTV) {
hexCode.setOnClickListener {
copyToClipboard(hexCode.text.toString())
}
hexCode.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) {
if (!isUpdatingFromHex) {
try {
val color = s.toString().toColorInt()
currentColor = color
isUpdatingFromHex = true
applyColor(color)
PreferencesHelper.saveCustomTheme(currentColor, -1)
(recyclerView.adapter as? ColorAdapter)?.resetSelection()
isUpdatingFromHex = false
} catch (_: Exception) {}
}
}
override fun afterTextChanged(s: Editable?) {}
})
} else {
hexCode.isFocusable = false
hexCode.isFocusableInTouchMode = false
}
setupRecyclerView()
}
private fun copyToClipboard(text: String) {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(getString(R.string.hex_code), text)
clipboard.setPrimaryClip(clip)
Toast.makeText(requireContext(), R.string.toastmessage_copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
private fun updateSeekBars(color: Int) {
seekRed.progress = Color.red(color)
seekGreen.progress = Color.green(color)
seekBlue.progress = Color.blue(color)
}
private fun updatePreview(color: Int) {
colorPreview.setBackgroundColor(color)
if (!isUpdatingFromHex) {
isUpdatingFromHex = true
hexCode.setText(String.format("#%08X", 0xFFFFFF and color))
isUpdatingFromHex = false
}
}
private fun setupRecyclerView() {
recyclerView.layoutManager = GridLayoutManager(requireContext(), 5)
val colors = ThemeHelper.getPredefinedColors(requireContext())
val adapter = ColorAdapter(colors) { color, index ->
currentColor = color
applyColor(color)
PreferencesHelper.saveCustomTheme(currentColor, index)
}
recyclerView.adapter = adapter
}
private inner class ColorAdapter(
private val colors: List<Int>,
private val onColorSelected: (Int, Int) -> Unit
) : RecyclerView.Adapter<ColorAdapter.ViewHolder>() {
private var selectedPosition: Int = -1
init {
selectedPosition = PreferencesHelper.loadCustomThemeIndex()
}
fun resetSelection() {
val oldPos = selectedPosition
selectedPosition = -1
if (oldPos != -1) notifyItemChanged(oldPos)
}
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val circle: View = view.findViewById(R.id.color_circle)
init {
view.isFocusable = true
view.isFocusableInTouchMode = isAndroidTV
view.setOnClickListener {
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
val oldPos = selectedPosition
selectedPosition = pos
if (oldPos != -1) notifyItemChanged(oldPos)
notifyItemChanged(selectedPosition)
onColorSelected(colors[pos], pos)
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.element_color_circle, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val color = colors[position]
val drawable = holder.circle.background as GradientDrawable
drawable.setColor(color)
// Set selection state
holder.itemView.isSelected = (position == selectedPosition)
}
override fun getItemCount() = colors.size
}
}
@@ -92,6 +92,9 @@ object Keys {
const val PREF_PRESET_DRC: String = "PRESET_DRC"
const val PREF_PRESET_STEREO_WIDTH: String = "PRESET_STEREO_WIDTH"
const val PREF_LANGUAGE_SELECTED: String = "PRESET_LANGUAGE_SELECTED"
const val PREF_CUSTOM_THEME_COLOR: String = "CUSTOM_THEME_COLOR"
const val PREF_CUSTOM_THEME_ENABLED: String = "CUSTOM_THEME_ENABLED"
const val PREF_CUSTOM_THEME_INDEX: String = "CUSTOM_THEME_INDEX"
// default const values
const val DEFAULT_SIZE_OF_METADATA_HISTORY: Int = 25
@@ -7,6 +7,7 @@ import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.util.TypedValue
import android.os.Build
import android.os.Bundle
import android.os.Handler
@@ -28,6 +29,7 @@ import com.michatec.radio.helpers.AppThemeHelper
import com.michatec.radio.helpers.FileHelper
import com.michatec.radio.helpers.LanguageHelper
import com.michatec.radio.helpers.PreferencesHelper
import com.michatec.radio.helpers.ThemeHelper
import org.woheller69.freeDroidWarn.FreeDroidWarn
import java.util.Locale
@@ -38,6 +40,7 @@ class MainActivity : AppCompatActivity() {
/* Main class variables */
private lateinit var appBarConfiguration: AppBarConfiguration
private lateinit var mainRoot: View
// Check if the device running the app is an Android TV instance
private val isAndroidTV: Boolean by lazy {
@@ -94,6 +97,8 @@ class MainActivity : AppCompatActivity() {
// set up views
setContentView(R.layout.activity_main)
mainRoot = findViewById(R.id.main_root)
applyCustomTheme()
// create .nomedia file - if not yet existing
FileHelper.createNomediaFile(getExternalFilesDir(null))
@@ -136,6 +141,33 @@ class MainActivity : AppCompatActivity() {
}
}
private fun applyCustomTheme() {
val enabled = PreferencesHelper.loadCustomThemeEnabled()
if (enabled) {
var color = PreferencesHelper.loadCustomThemeColor(this)
val index = PreferencesHelper.loadCustomThemeIndex()
if (index != -1) {
// Color belongs to a predefined group. Update it based on current mode.
val colors = ThemeHelper.getPredefinedColors(this)
if (index < colors.size) {
val updatedColor = colors[index]
if (updatedColor != color) {
color = updatedColor
// Save the updated color to keep preferences in sync with the current mode
PreferencesHelper.saveCustomThemeColor(color)
}
}
}
mainRoot.setBackgroundColor(color)
} else {
// Reset to default theme background color
val typedValue = TypedValue()
theme.resolveAttribute(android.R.attr.colorBackground, typedValue, true)
mainRoot.setBackgroundColor(typedValue.data)
}
}
/* Overrides onResume from AppCompatActivity */
override fun onResume() {
@@ -176,6 +208,9 @@ class MainActivity : AppCompatActivity() {
Keys.PREF_LANGUAGE_SELECTED -> {
LanguageHelper.setLanguage(this, PreferencesHelper.loadSelectedLanguage())
}
Keys.PREF_CUSTOM_THEME_COLOR, Keys.PREF_CUSTOM_THEME_ENABLED, Keys.PREF_CUSTOM_THEME_INDEX -> {
applyCustomTheme()
}
}
}
/*
@@ -308,6 +308,9 @@ class PlayerFragment : Fragment(),
if (key == Keys.PREF_PLAYER_METADATA_HISTORY) {
requestMetadataUpdate()
}
if (key == Keys.PREF_CUSTOM_THEME_COLOR || key == Keys.PREF_CUSTOM_THEME_ENABLED || key == Keys.PREF_CUSTOM_THEME_INDEX) {
layout.applyCustomTheme(activity as Context)
}
}
@@ -265,6 +265,7 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
return@setOnPreferenceClickListener true
}
// set up "Visualizer" preference entry
val preferenceVisualizer = Preference(context)
preferenceVisualizer.title = getString(R.string.pref_visualizer_title)
preferenceVisualizer.setIcon(R.drawable.ic_visualizer_24dp)
@@ -349,6 +350,7 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
return@setOnPreferenceClickListener true
}
// set up "Language Selection" preference
val preferenceLanguageSelection = Preference(context)
preferenceLanguageSelection.title = getString(R.string.pref_language_selection_title)
preferenceLanguageSelection.setIcon(R.drawable.ic_language_24dp)
@@ -361,6 +363,61 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
return@setOnPreferenceClickListener true
}
// set up "Custom Theme" preference
val preferenceCustomTheme = Preference(context)
preferenceCustomTheme.title = getString(R.string.pref_custom_theme_title)
preferenceCustomTheme.setIcon(R.drawable.ic_rbrush_24dp)
preferenceCustomTheme.summary = getString(R.string.pref_custom_theme_summary)
preferenceCustomTheme.isEnabled = PreferencesHelper.loadCustomThemeEnabled()
preferenceCustomTheme.setOnPreferenceClickListener {
findNavController().navigate(R.id.action_settings_to_cstheme)
return@setOnPreferenceClickListener true
}
// set up "Custom Theme Enabled" preference
val preferenceCustomThemeEnabled = MarqueeSwitchPreference(context)
preferenceCustomThemeEnabled.title = getString(R.string.pref_custom_theme_enabled_title)
preferenceCustomThemeEnabled.setIcon(R.drawable.ic_rbrush_24dp)
preferenceCustomThemeEnabled.summaryOn = getString(R.string.pref_custom_theme_enabled_summary)
preferenceCustomThemeEnabled.summaryOff = getString(R.string.pref_custom_theme_disabled_summary)
preferenceCustomThemeEnabled.key = Keys.PREF_CUSTOM_THEME_ENABLED
preferenceCustomThemeEnabled.setDefaultValue(PreferencesHelper.loadCustomThemeEnabled())
preferenceCustomThemeEnabled.setOnPreferenceChangeListener { _, newValue ->
when (newValue) {
true -> {
// enable custom theme
preferenceCustomTheme.isEnabled = true
}
false -> {
// disable custom theme
preferenceCustomTheme.isEnabled = false
}
}
return@setOnPreferenceChangeListener true
}
// set up "Share the App" preference
val preferenceShareApp = Preference(context)
preferenceShareApp.title = getString(R.string.pref_share_app_title)
preferenceShareApp.setIcon(R.drawable.ic_share_24dp)
preferenceShareApp.summary = getString(R.string.pref_share_app_summary)
preferenceShareApp.setOnPreferenceClickListener {
val shareIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.app_name))
putExtra(Intent.EXTRA_TEXT, getString(R.string.pref_share_app_share_text))
}
startActivity(shareIntent)
if (!isAndroidTV && isPermissionGranted(activity as Context, android.Manifest.permission.POST_NOTIFICATIONS)) {
NotificationSys.showNotification(
context,
getString(R.string.pref_share_app_thank_title),
getString(R.string.pref_share_app_thank_message)
)
}
return@setOnPreferenceClickListener true
}
// set preference categories
val preferenceCategoryGeneral = PreferenceCategory(activity as Context)
@@ -384,10 +441,13 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
// setup preference screen
screen.addPreference(preferenceAppVersion)
screen.addPreference(preferenceShareApp)
screen.addPreference(preferenceCategoryGeneral)
preferenceCategoryGeneral.addPreference(preferenceThemeSelection)
preferenceCategoryGeneral.addPreference(preferenceLanguageSelection)
preferenceCategoryGeneral.addPreference(preferenceCustomThemeEnabled)
preferenceCategoryGeneral.addPreference(preferenceCustomTheme)
if (!isAndroidTV && isPermissionGranted(activity as Context, android.Manifest.permission.POST_NOTIFICATIONS)) {
preferenceCategoryGeneral.addPreference(preferenceTestNotification)
@@ -39,6 +39,7 @@ class LanguageSelectionDialog(private var languageSelectionDialogListener: Langu
Language("de", R.string.pref_language_de),
Language("fr", R.string.pref_language_fr),
Language("ru", R.string.pref_language_ru),
Language("uk", R.string.pref_language_uk),
Language("ja", R.string.pref_language_ja),
Language("nl", R.string.pref_language_nl),
Language("pl", R.string.pref_language_pl),
@@ -4,12 +4,14 @@ import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.util.Log
import android.util.TypedValue
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.google.gson.Gson
import com.michatec.radio.Keys
import com.michatec.radio.ui.PlayerState
import java.util.*
import java.util.Calendar
import java.util.Date
/*
@@ -362,4 +364,33 @@ object PreferencesHelper {
}
}
/* Loads custom theme color */
fun loadCustomThemeColor(context: Context): Int {
val typedValue = TypedValue()
context.theme.resolveAttribute(android.R.attr.colorBackground, typedValue, true)
return sharedPreferences.getInt(Keys.PREF_CUSTOM_THEME_COLOR, typedValue.data)
}
/* Saves custom theme color */
fun saveCustomThemeColor(color: Int) {
sharedPreferences.edit { putInt(Keys.PREF_CUSTOM_THEME_COLOR, color) }
}
/* Loads custom theme index (predefined color index) */
fun loadCustomThemeIndex(): Int {
return sharedPreferences.getInt(Keys.PREF_CUSTOM_THEME_INDEX, -1)
}
/* Saves custom theme color and index together */
fun saveCustomTheme(color: Int, index: Int) {
sharedPreferences.edit {
putInt(Keys.PREF_CUSTOM_THEME_COLOR, color)
putInt(Keys.PREF_CUSTOM_THEME_INDEX, index)
}
}
/* Loads whether custom theme is enabled */
fun loadCustomThemeEnabled(): Boolean {
return sharedPreferences.getBoolean(Keys.PREF_CUSTOM_THEME_ENABLED, false)
}
}
@@ -0,0 +1,40 @@
package com.michatec.radio.helpers
import android.content.Context
import android.content.res.Configuration
import androidx.core.graphics.toColorInt
object ThemeHelper {
fun getPredefinedColors(context: Context): List<Int> {
val isDarkMode = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
return if (isDarkMode) {
// Darker colors for dark mode background
listOf(
"#FF1D3E66".toColorInt(), // Default Dark
"#FF3E1D1D".toColorInt(), // Red Dark
"#FF1D3E3E".toColorInt(), // Teal Dark
"#FF3E1D2E".toColorInt(), // Pink Dark
"#FF001A33".toColorInt(), // Dark Blue Dark
"#FF1D3E1D".toColorInt(), // Green Dark
"#FF3E2E1D".toColorInt(), // Orange Dark
"#FF2E1D1D".toColorInt(), // Brown Dark
"#FF1D242E".toColorInt(), // Blue Grey Dark
"#FF000000".toColorInt() // Black
)
} else {
// Lighter colors for light mode background
listOf(
"#FFDAE2FF".toColorInt(), // Light Default
"#FFFF897D".toColorInt(), // Light Red
"#FF4DB6AC".toColorInt(), // Light Teal
"#FFF48FB1".toColorInt(), // Light Pink
"#FF90CAF9".toColorInt(), // Light Blue
"#FFA5D6A7".toColorInt(), // Light Green
"#FFFFAB91".toColorInt(), // Light Orange
"#FFBCAAA4".toColorInt(), // Light Brown
"#FFB0BEC5".toColorInt(), // Light Blue Grey
"#FFFFFFFF".toColorInt() // White
)
}
}
}
@@ -6,7 +6,6 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.AnimatedVectorDrawable
import java.util.Locale
import android.view.View
import android.view.animation.Animation
import android.view.animation.AnimationUtils
@@ -31,7 +30,9 @@ import com.michatec.radio.core.Station
import com.michatec.radio.helpers.DateTimeHelper
import com.michatec.radio.helpers.ImageHelper
import com.michatec.radio.helpers.PreferencesHelper
import com.michatec.radio.helpers.ThemeHelper
import com.michatec.radio.helpers.UiHelper
import java.util.Locale
/*
@@ -122,6 +123,9 @@ data class LayoutHolder(var rootView: View) {
CastButtonFactory.setUpMediaRouteButton(rootView.context, it)
}
// Apply custom theme color
applyCustomTheme(rootView.context)
// set layout for player
setupBottomSheet()
}
@@ -182,6 +186,9 @@ data class LayoutHolder(var rootView: View) {
// update bitrate
sheetBitrateView?.text = bitrateText
// update custom theme
applyCustomTheme(context)
// update click listeners
sheetStreamingLinkHeadline?.setOnClickListener {
copyToClipboard(
@@ -309,6 +316,28 @@ data class LayoutHolder(var rootView: View) {
isBuffering = buffering
}
/* Applies custom theme color to the UI */
fun applyCustomTheme(context: Context) {
val enabled = PreferencesHelper.loadCustomThemeEnabled()
if (enabled) {
var customColor = PreferencesHelper.loadCustomThemeColor(context)
val index = PreferencesHelper.loadCustomThemeIndex()
if (index != -1) {
val colors = ThemeHelper.getPredefinedColors(context)
if (index < colors.size) {
customColor = colors[index]
}
}
rootView.setBackgroundColor(customColor)
recyclerView.setBackgroundColor(customColor)
} else {
rootView.setBackgroundResource(android.R.color.transparent)
recyclerView.setBackgroundResource(android.R.color.transparent)
}
}
/* Toggles visibility of the download progress indicator */
fun toggleDownloadProgressIndicator() {
when (PreferencesHelper.loadActiveDownloads()) {