Skip to content

Commit 762083f

Browse files
committed
Add two-tier theme picker (modern named list + Classic colors grid)
Replace the single color-swatch grid with a ThemePickerPreference that opens a modern named list of full-fledged Material 3 themes (preview swatch + name), with a trailing 'Classic colors...' row that opens the original swatch grid for the legacy single-accent palettes. - New ThemePickerPreference supersedes ColorPickerPreference (removed). - Theme.MODERN_THEMES declarative catalog (Dynamic for now); legacy grid skips any id present in the modern list so there's no duplication. - Persistence unchanged: same appTheme int key, same persist-then-notify ordering, so theme apply/recreate behavior is identical. No theme behavior changes; this is an isolated picker refactor.
1 parent 6b9c51d commit 762083f

6 files changed

Lines changed: 247 additions & 120 deletions

File tree

app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
6464
DataStore.initGlobal()
6565
addPreferencesFromResource(R.xml.global_preferences)
6666

67-
val appTheme = findPreference<ColorPickerPreference>(Key.APP_THEME)!!
67+
val appTheme = findPreference<ThemePickerPreference>(Key.APP_THEME)!!
6868
val nightTheme = findPreference<SimpleMenuPreference>(Key.NIGHT_THEME)!!
6969
appTheme.setOnPreferenceChangeListener { _, newTheme ->
7070
if (DataStore.serviceState.started) {

app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ object Theme {
3636

3737
private fun defaultTheme() = PINK_SSR
3838

39+
/**
40+
* Metadata for a theme shown in the modern named picker.
41+
*
42+
* @param id one of the Theme int constants above (persisted to appTheme)
43+
* @param nameRes display name string resource
44+
* @param previewColor color resource for the preview swatch shown next to the name
45+
*/
46+
data class ThemeInfo(val id: Int, val nameRes: Int, val previewColor: Int)
47+
48+
/**
49+
* Modern, full-fledged M3 themes presented by name in the picker dialog.
50+
* The legacy single-accent palettes stay behind the "Classic colors…" grid.
51+
* Order here is the display order in the dialog.
52+
*/
53+
val MODERN_THEMES: List<ThemeInfo> = listOf(
54+
ThemeInfo(DYNAMIC, R.string.theme_dynamic, R.color.color_dynamic_swatch),
55+
)
56+
3957
fun apply(context: Context) {
4058
context.setTheme(getTheme())
4159
}

app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt

Lines changed: 0 additions & 118 deletions
This file was deleted.
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package moe.matsuri.nb4a.ui
2+
3+
import android.content.Context
4+
import android.content.res.Resources
5+
import android.graphics.drawable.Drawable
6+
import android.util.AttributeSet
7+
import android.view.Gravity
8+
import android.view.View
9+
import android.view.ViewGroup
10+
import android.widget.GridLayout
11+
import android.widget.ImageView
12+
import android.widget.LinearLayout
13+
import android.widget.TextView
14+
import androidx.appcompat.app.AlertDialog
15+
import androidx.core.content.res.ResourcesCompat
16+
import androidx.core.content.res.TypedArrayUtils
17+
import androidx.core.graphics.drawable.DrawableCompat
18+
import androidx.core.view.setPadding
19+
import androidx.preference.Preference
20+
import androidx.preference.PreferenceViewHolder
21+
import com.google.android.material.dialog.MaterialAlertDialogBuilder
22+
import io.nekohasekai.sagernet.R
23+
import io.nekohasekai.sagernet.ktx.getColorAttr
24+
import io.nekohasekai.sagernet.utils.Theme
25+
import kotlin.math.roundToInt
26+
27+
/**
28+
* Two-tier theme picker.
29+
*
30+
* The preference opens a modern named list of full-fledged Material 3 themes
31+
* (each row: preview swatch + name). A trailing "Classic colors…" row opens the
32+
* legacy single-accent color grid (the original swatch-grid UX).
33+
*
34+
* Both paths persist the same int [Theme] id via [persistInt] so the rest of the
35+
* app (Theme.getTheme / DataStore.appTheme) is unchanged.
36+
*/
37+
class ThemePickerPreference
38+
@JvmOverloads constructor(
39+
context: Context, attrs: AttributeSet? = null, defStyle: Int = TypedArrayUtils.getAttr(
40+
context,
41+
androidx.preference.R.attr.editTextPreferenceStyle,
42+
android.R.attr.editTextPreferenceStyle
43+
)
44+
) : Preference(
45+
context, attrs, defStyle
46+
) {
47+
48+
private var inited = false
49+
50+
override fun onBindViewHolder(holder: PreferenceViewHolder) {
51+
super.onBindViewHolder(holder)
52+
53+
val widgetFrame = holder.findViewById(android.R.id.widget_frame) as LinearLayout
54+
55+
if (!inited) {
56+
inited = true
57+
widgetFrame.addView(
58+
nekoImageView(context.getColorAttr(R.attr.colorPrimary), 48, 0)
59+
)
60+
widgetFrame.visibility = View.VISIBLE
61+
}
62+
}
63+
64+
private fun nekoImageView(color: Int, sizeDp: Int, paddingDp: Int): ImageView {
65+
val factor = context.resources.displayMetrics.density
66+
val size = (sizeDp * factor).roundToInt()
67+
val paddingSize = (paddingDp * factor).roundToInt()
68+
69+
return ImageView(context).apply {
70+
layoutParams = ViewGroup.LayoutParams(size, size)
71+
setPadding(paddingSize)
72+
setImageDrawable(nekoAtColor(resources, color))
73+
}
74+
}
75+
76+
private fun nekoAtColor(res: Resources, color: Int): Drawable {
77+
val neko = ResourcesCompat.getDrawable(
78+
res, R.drawable.ic_baseline_fiber_manual_record_24, null
79+
)!!
80+
DrawableCompat.setTint(neko.mutate(), color)
81+
return neko
82+
}
83+
84+
private fun dp(value: Int): Int =
85+
(value * context.resources.displayMetrics.density).roundToInt()
86+
87+
override fun onClick() {
88+
super.onClick()
89+
showModernDialog()
90+
}
91+
92+
/** Modern named list: one row per [Theme.MODERN_THEMES] entry + a "Classic colors…" row. */
93+
private fun showModernDialog() {
94+
lateinit var dialog: AlertDialog
95+
96+
val container = LinearLayout(context).apply {
97+
orientation = LinearLayout.VERTICAL
98+
setPadding(dp(8), dp(8), dp(8), dp(8))
99+
}
100+
101+
for (info in Theme.MODERN_THEMES) {
102+
container.addView(
103+
buildRow(
104+
name = context.getString(info.nameRes),
105+
swatchColor = ResourcesCompat.getColor(context.resources, info.previewColor, context.theme),
106+
) {
107+
select(info.id)
108+
dialog.dismiss()
109+
}
110+
)
111+
}
112+
113+
// Trailing entry: open the legacy single-accent color grid.
114+
container.addView(
115+
buildRow(
116+
name = context.getString(R.string.theme_classic_colors),
117+
swatchColor = null,
118+
icon = R.drawable.ic_baseline_color_lens_24,
119+
) {
120+
dialog.dismiss()
121+
showClassicGrid()
122+
}
123+
)
124+
125+
dialog = MaterialAlertDialogBuilder(context)
126+
.setTitle(title)
127+
.setView(container)
128+
.setNegativeButton(android.R.string.cancel, null)
129+
.show()
130+
}
131+
132+
private fun buildRow(
133+
name: String,
134+
swatchColor: Int?,
135+
icon: Int? = null,
136+
onClick: () -> Unit,
137+
): View {
138+
return LinearLayout(context).apply {
139+
orientation = LinearLayout.HORIZONTAL
140+
gravity = Gravity.CENTER_VERTICAL
141+
isClickable = true
142+
isFocusable = true
143+
setPadding(dp(16), dp(14), dp(16), dp(14))
144+
// Selectable item background for ripple feedback.
145+
val outValue = android.util.TypedValue()
146+
context.theme.resolveAttribute(
147+
android.R.attr.selectableItemBackground, outValue, true
148+
)
149+
setBackgroundResource(outValue.resourceId)
150+
151+
val leading = when {
152+
swatchColor != null -> nekoImageView(swatchColor, 28, 0)
153+
icon != null -> ImageView(context).apply {
154+
layoutParams = ViewGroup.LayoutParams(dp(28), dp(28))
155+
setImageDrawable(
156+
ResourcesCompat.getDrawable(resources, icon, context.theme)?.also {
157+
DrawableCompat.setTint(
158+
it.mutate(), context.getColorAttr(android.R.attr.textColorPrimary)
159+
)
160+
}
161+
)
162+
}
163+
else -> View(context).apply {
164+
layoutParams = ViewGroup.LayoutParams(dp(28), dp(28))
165+
}
166+
}
167+
addView(leading)
168+
169+
addView(TextView(context).apply {
170+
text = name
171+
setPadding(dp(16), 0, 0, 0)
172+
textSize = 16f
173+
setTextColor(context.getColorAttr(android.R.attr.textColorPrimary))
174+
layoutParams = LinearLayout.LayoutParams(
175+
0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f
176+
)
177+
})
178+
179+
setOnClickListener { onClick() }
180+
}
181+
}
182+
183+
/** Legacy single-accent color grid (the original swatch-grid UX). */
184+
private fun showClassicGrid() {
185+
lateinit var dialog: AlertDialog
186+
187+
val grid = GridLayout(context).apply {
188+
columnCount = 4
189+
190+
val colors = context.resources.getIntArray(R.array.material_colors)
191+
for ((index, color) in colors.withIndex()) {
192+
// Swatch index+1 is the Theme id (see Theme.kt). Skip themes that
193+
// are presented in the modern named list instead of the grid.
194+
val themeId = index + 1
195+
if (Theme.MODERN_THEMES.any { it.id == themeId }) continue
196+
197+
addView(nekoImageView(color, 64, 0).apply {
198+
setOnClickListener {
199+
select(themeId)
200+
dialog.dismiss()
201+
}
202+
})
203+
}
204+
}
205+
206+
dialog = MaterialAlertDialogBuilder(context)
207+
.setTitle(R.string.theme_classic_colors)
208+
.setView(LinearLayout(context).apply {
209+
gravity = Gravity.CENTER
210+
layoutParams = ViewGroup.LayoutParams(
211+
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
212+
)
213+
addView(grid)
214+
})
215+
.setNegativeButton(android.R.string.cancel, null)
216+
.show()
217+
}
218+
219+
private fun select(themeId: Int) {
220+
// Match the legacy swatch-grid ordering: persist first, then notify the
221+
// change listener (SettingsPreferenceFragment applies/recreates).
222+
persistInt(themeId)
223+
callChangeListener(themeId)
224+
}
225+
}

app/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
<string name="menu_group">Group</string>
1515
<string name="menu_about">About</string>
1616
<string name="theme">Theme</string>
17+
<string name="theme_dynamic">Dynamic (Material You)</string>
18+
<string name="theme_classic_colors">Classic colors…</string>
1719
<string name="document">Document</string>
1820
<string name="group_default">Ungrouped</string>
1921
<string name="quick_toggle">Toggle</string>

app/src/main/res/xml/global_preferences.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
app:key="isAutoConnect"
99
app:summary="@string/auto_connect_summary"
1010
app:title="@string/auto_connect" />
11-
<moe.matsuri.nb4a.ui.ColorPickerPreference
11+
<moe.matsuri.nb4a.ui.ThemePickerPreference
1212
android:title="@string/theme"
1313
app:icon="@drawable/ic_baseline_color_lens_24"
1414
app:key="appTheme" />

0 commit comments

Comments
 (0)