Skip to content

Commit 181ebd4

Browse files
committed
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.
1 parent 4429ed4 commit 181ebd4

29 files changed

Lines changed: 947 additions & 3 deletions
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package com.michatec.radio
2+
3+
import android.content.ClipData
4+
import android.content.ClipboardManager
5+
import android.content.Context
6+
import android.content.pm.PackageManager
7+
import android.graphics.Color
8+
import android.graphics.drawable.GradientDrawable
9+
import android.os.Bundle
10+
import android.text.Editable
11+
import android.text.TextWatcher
12+
import android.view.LayoutInflater
13+
import android.view.View
14+
import android.view.ViewGroup
15+
import android.widget.SeekBar
16+
import android.widget.Toast
17+
import androidx.appcompat.app.AppCompatActivity
18+
import androidx.core.graphics.toColorInt
19+
import androidx.fragment.app.Fragment
20+
import androidx.recyclerview.widget.GridLayoutManager
21+
import androidx.recyclerview.widget.RecyclerView
22+
import com.google.android.material.textfield.TextInputEditText
23+
import com.michatec.radio.helpers.PreferencesHelper
24+
import com.michatec.radio.helpers.ThemeHelper
25+
26+
class CustomThemeFragment : Fragment() {
27+
28+
private lateinit var colorPreview: View
29+
private lateinit var hexCode: TextInputEditText
30+
private lateinit var seekRed: SeekBar
31+
private lateinit var seekGreen: SeekBar
32+
private lateinit var seekBlue: SeekBar
33+
private lateinit var recyclerView: RecyclerView
34+
35+
private var currentColor: Int = Color.BLACK
36+
private var isUpdatingFromHex = false
37+
38+
private fun applyColor(
39+
color: Int
40+
) {
41+
updateSeekBars(color)
42+
updatePreview(color)
43+
}
44+
45+
private val isAndroidTV: Boolean by lazy {
46+
requireContext().packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
47+
}
48+
49+
override fun onCreateView(
50+
inflater: LayoutInflater, container: ViewGroup?,
51+
savedInstanceState: Bundle?
52+
): View? {
53+
return inflater.inflate(R.layout.fragment_custom_theme, container, false)
54+
}
55+
56+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
57+
super.onViewCreated(view, savedInstanceState)
58+
59+
(activity as? AppCompatActivity)?.supportActionBar?.title = getString(R.string.pref_custom_theme_title)
60+
61+
colorPreview = view.findViewById(R.id.color_preview)
62+
hexCode = view.findViewById(R.id.hex_code)
63+
seekRed = view.findViewById(R.id.seek_red)
64+
seekGreen = view.findViewById(R.id.seek_green)
65+
seekBlue = view.findViewById(R.id.seek_blue)
66+
recyclerView = view.findViewById(R.id.color_recycler_view)
67+
68+
currentColor = PreferencesHelper.loadCustomThemeColor(requireContext())
69+
70+
applyColor(currentColor)
71+
72+
val seekBarListener = object : SeekBar.OnSeekBarChangeListener {
73+
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
74+
if (fromUser) {
75+
val r = seekRed.progress
76+
val g = seekGreen.progress
77+
val b = seekBlue.progress
78+
currentColor = Color.rgb(r, g, b)
79+
updatePreview(currentColor)
80+
PreferencesHelper.saveCustomTheme(currentColor, -1)
81+
(recyclerView.adapter as? ColorAdapter)?.resetSelection()
82+
}
83+
}
84+
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
85+
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
86+
}
87+
88+
seekRed.setOnSeekBarChangeListener(seekBarListener)
89+
seekGreen.setOnSeekBarChangeListener(seekBarListener)
90+
seekBlue.setOnSeekBarChangeListener(seekBarListener)
91+
92+
// Clipboard logic (Non-TV)
93+
if (!isAndroidTV) {
94+
hexCode.setOnClickListener {
95+
copyToClipboard(hexCode.text.toString())
96+
}
97+
hexCode.addTextChangedListener(object : TextWatcher {
98+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
99+
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
100+
if (!isUpdatingFromHex) {
101+
try {
102+
val color = s.toString().toColorInt()
103+
currentColor = color
104+
isUpdatingFromHex = true
105+
applyColor(color)
106+
PreferencesHelper.saveCustomTheme(currentColor, -1)
107+
(recyclerView.adapter as? ColorAdapter)?.resetSelection()
108+
isUpdatingFromHex = false
109+
} catch (_: Exception) {}
110+
}
111+
}
112+
override fun afterTextChanged(s: Editable?) {}
113+
})
114+
} else {
115+
hexCode.isFocusable = false
116+
hexCode.isFocusableInTouchMode = false
117+
}
118+
119+
setupRecyclerView()
120+
}
121+
122+
private fun copyToClipboard(text: String) {
123+
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
124+
val clip = ClipData.newPlainText(getString(R.string.hex_code), text)
125+
clipboard.setPrimaryClip(clip)
126+
Toast.makeText(requireContext(), R.string.toastmessage_copied_to_clipboard, Toast.LENGTH_SHORT).show()
127+
}
128+
129+
private fun updateSeekBars(color: Int) {
130+
seekRed.progress = Color.red(color)
131+
seekGreen.progress = Color.green(color)
132+
seekBlue.progress = Color.blue(color)
133+
}
134+
135+
private fun updatePreview(color: Int) {
136+
colorPreview.setBackgroundColor(color)
137+
if (!isUpdatingFromHex) {
138+
isUpdatingFromHex = true
139+
hexCode.setText(String.format("#%08X", 0xFFFFFF and color))
140+
isUpdatingFromHex = false
141+
}
142+
}
143+
144+
private fun setupRecyclerView() {
145+
recyclerView.layoutManager = GridLayoutManager(requireContext(), 5)
146+
val colors = ThemeHelper.getPredefinedColors(requireContext())
147+
val adapter = ColorAdapter(colors) { color, index ->
148+
currentColor = color
149+
applyColor(color)
150+
PreferencesHelper.saveCustomTheme(currentColor, index)
151+
}
152+
recyclerView.adapter = adapter
153+
}
154+
155+
private inner class ColorAdapter(
156+
private val colors: List<Int>,
157+
private val onColorSelected: (Int, Int) -> Unit
158+
) : RecyclerView.Adapter<ColorAdapter.ViewHolder>() {
159+
160+
private var selectedPosition: Int = -1
161+
162+
init {
163+
selectedPosition = PreferencesHelper.loadCustomThemeIndex()
164+
}
165+
166+
fun resetSelection() {
167+
val oldPos = selectedPosition
168+
selectedPosition = -1
169+
if (oldPos != -1) notifyItemChanged(oldPos)
170+
}
171+
172+
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
173+
val circle: View = view.findViewById(R.id.color_circle)
174+
init {
175+
view.isFocusable = true
176+
view.isFocusableInTouchMode = isAndroidTV
177+
view.setOnClickListener {
178+
val pos = bindingAdapterPosition
179+
if (pos != RecyclerView.NO_POSITION) {
180+
val oldPos = selectedPosition
181+
selectedPosition = pos
182+
if (oldPos != -1) notifyItemChanged(oldPos)
183+
notifyItemChanged(selectedPosition)
184+
onColorSelected(colors[pos], pos)
185+
}
186+
}
187+
}
188+
}
189+
190+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
191+
val view = LayoutInflater.from(parent.context)
192+
.inflate(R.layout.element_color_circle, parent, false)
193+
return ViewHolder(view)
194+
}
195+
196+
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
197+
val color = colors[position]
198+
val drawable = holder.circle.background as GradientDrawable
199+
drawable.setColor(color)
200+
201+
// Set selection state
202+
holder.itemView.isSelected = (position == selectedPosition)
203+
}
204+
205+
override fun getItemCount() = colors.size
206+
}
207+
}

app/src/main/java/com/michatec/radio/Keys.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ object Keys {
9292
const val PREF_PRESET_DRC: String = "PRESET_DRC"
9393
const val PREF_PRESET_STEREO_WIDTH: String = "PRESET_STEREO_WIDTH"
9494
const val PREF_LANGUAGE_SELECTED: String = "PRESET_LANGUAGE_SELECTED"
95+
const val PREF_CUSTOM_THEME_COLOR: String = "CUSTOM_THEME_COLOR"
96+
const val PREF_CUSTOM_THEME_ENABLED: String = "CUSTOM_THEME_ENABLED"
97+
const val PREF_CUSTOM_THEME_INDEX: String = "CUSTOM_THEME_INDEX"
9598

9699
// default const values
97100
const val DEFAULT_SIZE_OF_METADATA_HISTORY: Int = 25

app/src/main/java/com/michatec/radio/MainActivity.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import android.content.SharedPreferences
77
import android.content.pm.PackageManager
88
import android.content.res.Configuration
99
import android.net.Uri
10+
import android.util.TypedValue
1011
import android.os.Build
1112
import android.os.Bundle
1213
import android.os.Handler
@@ -28,6 +29,7 @@ import com.michatec.radio.helpers.AppThemeHelper
2829
import com.michatec.radio.helpers.FileHelper
2930
import com.michatec.radio.helpers.LanguageHelper
3031
import com.michatec.radio.helpers.PreferencesHelper
32+
import com.michatec.radio.helpers.ThemeHelper
3133
import org.woheller69.freeDroidWarn.FreeDroidWarn
3234
import java.util.Locale
3335

@@ -38,6 +40,7 @@ class MainActivity : AppCompatActivity() {
3840

3941
/* Main class variables */
4042
private lateinit var appBarConfiguration: AppBarConfiguration
43+
private lateinit var mainRoot: View
4144

4245
// Check if the device running the app is an Android TV instance
4346
private val isAndroidTV: Boolean by lazy {
@@ -94,6 +97,8 @@ class MainActivity : AppCompatActivity() {
9497

9598
// set up views
9699
setContentView(R.layout.activity_main)
100+
mainRoot = findViewById(R.id.main_root)
101+
applyCustomTheme()
97102

98103
// create .nomedia file - if not yet existing
99104
FileHelper.createNomediaFile(getExternalFilesDir(null))
@@ -136,6 +141,33 @@ class MainActivity : AppCompatActivity() {
136141
}
137142
}
138143

144+
private fun applyCustomTheme() {
145+
val enabled = PreferencesHelper.loadCustomThemeEnabled()
146+
if (enabled) {
147+
var color = PreferencesHelper.loadCustomThemeColor(this)
148+
val index = PreferencesHelper.loadCustomThemeIndex()
149+
150+
if (index != -1) {
151+
// Color belongs to a predefined group. Update it based on current mode.
152+
val colors = ThemeHelper.getPredefinedColors(this)
153+
if (index < colors.size) {
154+
val updatedColor = colors[index]
155+
if (updatedColor != color) {
156+
color = updatedColor
157+
// Save the updated color to keep preferences in sync with the current mode
158+
PreferencesHelper.saveCustomThemeColor(color)
159+
}
160+
}
161+
}
162+
mainRoot.setBackgroundColor(color)
163+
} else {
164+
// Reset to default theme background color
165+
val typedValue = TypedValue()
166+
theme.resolveAttribute(android.R.attr.colorBackground, typedValue, true)
167+
mainRoot.setBackgroundColor(typedValue.data)
168+
}
169+
}
170+
139171

140172
/* Overrides onResume from AppCompatActivity */
141173
override fun onResume() {
@@ -176,6 +208,9 @@ class MainActivity : AppCompatActivity() {
176208
Keys.PREF_LANGUAGE_SELECTED -> {
177209
LanguageHelper.setLanguage(this, PreferencesHelper.loadSelectedLanguage())
178210
}
211+
Keys.PREF_CUSTOM_THEME_COLOR, Keys.PREF_CUSTOM_THEME_ENABLED, Keys.PREF_CUSTOM_THEME_INDEX -> {
212+
applyCustomTheme()
213+
}
179214
}
180215
}
181216
/*

app/src/main/java/com/michatec/radio/PlayerFragment.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@ class PlayerFragment : Fragment(),
308308
if (key == Keys.PREF_PLAYER_METADATA_HISTORY) {
309309
requestMetadataUpdate()
310310
}
311+
if (key == Keys.PREF_CUSTOM_THEME_COLOR || key == Keys.PREF_CUSTOM_THEME_ENABLED || key == Keys.PREF_CUSTOM_THEME_INDEX) {
312+
layout.applyCustomTheme(activity as Context)
313+
}
311314
}
312315

313316

app/src/main/java/com/michatec/radio/SettingsFragment.kt

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
265265
return@setOnPreferenceClickListener true
266266
}
267267

268+
// set up "Visualizer" preference entry
268269
val preferenceVisualizer = Preference(context)
269270
preferenceVisualizer.title = getString(R.string.pref_visualizer_title)
270271
preferenceVisualizer.setIcon(R.drawable.ic_visualizer_24dp)
@@ -349,6 +350,7 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
349350
return@setOnPreferenceClickListener true
350351
}
351352

353+
// set up "Language Selection" preference
352354
val preferenceLanguageSelection = Preference(context)
353355
preferenceLanguageSelection.title = getString(R.string.pref_language_selection_title)
354356
preferenceLanguageSelection.setIcon(R.drawable.ic_language_24dp)
@@ -361,6 +363,61 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
361363
return@setOnPreferenceClickListener true
362364
}
363365

366+
// set up "Custom Theme" preference
367+
val preferenceCustomTheme = Preference(context)
368+
preferenceCustomTheme.title = getString(R.string.pref_custom_theme_title)
369+
preferenceCustomTheme.setIcon(R.drawable.ic_rbrush_24dp)
370+
preferenceCustomTheme.summary = getString(R.string.pref_custom_theme_summary)
371+
preferenceCustomTheme.isEnabled = PreferencesHelper.loadCustomThemeEnabled()
372+
preferenceCustomTheme.setOnPreferenceClickListener {
373+
findNavController().navigate(R.id.action_settings_to_cstheme)
374+
return@setOnPreferenceClickListener true
375+
}
376+
377+
// set up "Custom Theme Enabled" preference
378+
val preferenceCustomThemeEnabled = MarqueeSwitchPreference(context)
379+
preferenceCustomThemeEnabled.title = getString(R.string.pref_custom_theme_enabled_title)
380+
preferenceCustomThemeEnabled.setIcon(R.drawable.ic_rbrush_24dp)
381+
preferenceCustomThemeEnabled.summaryOn = getString(R.string.pref_custom_theme_enabled_summary)
382+
preferenceCustomThemeEnabled.summaryOff = getString(R.string.pref_custom_theme_disabled_summary)
383+
preferenceCustomThemeEnabled.key = Keys.PREF_CUSTOM_THEME_ENABLED
384+
preferenceCustomThemeEnabled.setDefaultValue(PreferencesHelper.loadCustomThemeEnabled())
385+
preferenceCustomThemeEnabled.setOnPreferenceChangeListener { _, newValue ->
386+
when (newValue) {
387+
true -> {
388+
// enable custom theme
389+
preferenceCustomTheme.isEnabled = true
390+
}
391+
false -> {
392+
// disable custom theme
393+
preferenceCustomTheme.isEnabled = false
394+
}
395+
}
396+
return@setOnPreferenceChangeListener true
397+
}
398+
399+
// set up "Share the App" preference
400+
val preferenceShareApp = Preference(context)
401+
preferenceShareApp.title = getString(R.string.pref_share_app_title)
402+
preferenceShareApp.setIcon(R.drawable.ic_share_24dp)
403+
preferenceShareApp.summary = getString(R.string.pref_share_app_summary)
404+
preferenceShareApp.setOnPreferenceClickListener {
405+
val shareIntent = Intent().apply {
406+
action = Intent.ACTION_SEND
407+
type = "text/plain"
408+
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.app_name))
409+
putExtra(Intent.EXTRA_TEXT, getString(R.string.pref_share_app_share_text))
410+
}
411+
startActivity(shareIntent)
412+
if (!isAndroidTV && isPermissionGranted(activity as Context, android.Manifest.permission.POST_NOTIFICATIONS)) {
413+
NotificationSys.showNotification(
414+
context,
415+
getString(R.string.pref_share_app_thank_title),
416+
getString(R.string.pref_share_app_thank_message)
417+
)
418+
}
419+
return@setOnPreferenceClickListener true
420+
}
364421

365422
// set preference categories
366423
val preferenceCategoryGeneral = PreferenceCategory(activity as Context)
@@ -384,10 +441,13 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
384441

385442
// setup preference screen
386443
screen.addPreference(preferenceAppVersion)
444+
screen.addPreference(preferenceShareApp)
387445

388446
screen.addPreference(preferenceCategoryGeneral)
389447
preferenceCategoryGeneral.addPreference(preferenceThemeSelection)
390448
preferenceCategoryGeneral.addPreference(preferenceLanguageSelection)
449+
preferenceCategoryGeneral.addPreference(preferenceCustomThemeEnabled)
450+
preferenceCategoryGeneral.addPreference(preferenceCustomTheme)
391451

392452
if (!isAndroidTV && isPermissionGranted(activity as Context, android.Manifest.permission.POST_NOTIFICATIONS)) {
393453
preferenceCategoryGeneral.addPreference(preferenceTestNotification)

0 commit comments

Comments
 (0)