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
}
}