From 762083f78cae17aaa2e8677822d2fbd7a7cdc71b Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:16:39 -0400 Subject: [PATCH 01/15] 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. --- .../sagernet/ui/SettingsPreferenceFragment.kt | 2 +- .../io/nekohasekai/sagernet/utils/Theme.kt | 18 ++ .../matsuri/nb4a/ui/ColorPickerPreference.kt | 118 --------- .../matsuri/nb4a/ui/ThemePickerPreference.kt | 225 ++++++++++++++++++ app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/global_preferences.xml | 2 +- 6 files changed, 247 insertions(+), 120 deletions(-) delete mode 100644 app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt create mode 100644 app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt index e245e658e..d54b10878 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt @@ -64,7 +64,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { DataStore.initGlobal() addPreferencesFromResource(R.xml.global_preferences) - val appTheme = findPreference(Key.APP_THEME)!! + val appTheme = findPreference(Key.APP_THEME)!! val nightTheme = findPreference(Key.NIGHT_THEME)!! appTheme.setOnPreferenceChangeListener { _, newTheme -> if (DataStore.serviceState.started) { diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt index 2e228a58c..9880e556a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt @@ -36,6 +36,24 @@ object Theme { private fun defaultTheme() = PINK_SSR + /** + * Metadata for a theme shown in the modern named picker. + * + * @param id one of the Theme int constants above (persisted to appTheme) + * @param nameRes display name string resource + * @param previewColor color resource for the preview swatch shown next to the name + */ + data class ThemeInfo(val id: Int, val nameRes: Int, val previewColor: Int) + + /** + * Modern, full-fledged M3 themes presented by name in the picker dialog. + * The legacy single-accent palettes stay behind the "Classic colors…" grid. + * Order here is the display order in the dialog. + */ + val MODERN_THEMES: List = listOf( + ThemeInfo(DYNAMIC, R.string.theme_dynamic, R.color.color_dynamic_swatch), + ) + fun apply(context: Context) { context.setTheme(getTheme()) } diff --git a/app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt b/app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt deleted file mode 100644 index da87e9c24..000000000 --- a/app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt +++ /dev/null @@ -1,118 +0,0 @@ -package moe.matsuri.nb4a.ui - -import android.content.Context -import android.content.res.Resources -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.GridLayout -import android.widget.ImageView -import android.widget.LinearLayout -import androidx.appcompat.app.AlertDialog -import androidx.core.content.res.ResourcesCompat -import androidx.core.content.res.TypedArrayUtils -import androidx.core.graphics.drawable.DrawableCompat -import androidx.core.view.setPadding -import androidx.preference.Preference -import androidx.preference.PreferenceViewHolder -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.nekohasekai.sagernet.R -import io.nekohasekai.sagernet.ktx.getColorAttr -import kotlin.math.roundToInt - -class ColorPickerPreference -@JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null, defStyle: Int = TypedArrayUtils.getAttr( - context, - androidx.preference.R.attr.editTextPreferenceStyle, - android.R.attr.editTextPreferenceStyle - ) -) : Preference( - context, attrs, defStyle -) { - - var inited = false - - override fun onBindViewHolder(holder: PreferenceViewHolder) { - super.onBindViewHolder(holder) - - val widgetFrame = holder.findViewById(android.R.id.widget_frame) as LinearLayout - - if (!inited) { - inited = true - - widgetFrame.addView( - getNekoImageViewAtColor( - context.getColorAttr(R.attr.colorPrimary), - 48, - 0 - ) - ) - widgetFrame.visibility = View.VISIBLE - } - } - - fun getNekoImageViewAtColor(color: Int, sizeDp: Int, paddingDp: Int): ImageView { - // dp to pixel - val factor = context.resources.displayMetrics.density - val size = (sizeDp * factor).roundToInt() - val paddingSize = (paddingDp * factor).roundToInt() - - return ImageView(context).apply { - layoutParams = ViewGroup.LayoutParams(size, size) - setPadding(paddingSize) - setImageDrawable(getNekoAtColor(resources, color)) - } - } - - fun getNekoAtColor(res: Resources, color: Int): Drawable { - val neko = ResourcesCompat.getDrawable( - res, - R.drawable.ic_baseline_fiber_manual_record_24, - null - )!! - DrawableCompat.setTint(neko.mutate(), color) - return neko - } - - override fun onClick() { - super.onClick() - - lateinit var dialog: AlertDialog - - val grid = GridLayout(context).apply { - columnCount = 4 - - val colors = context.resources.getIntArray(R.array.material_colors) - var i = 0 - - for (color in colors) { - i++ //Theme.kt - - val themeId = i - val view = getNekoImageViewAtColor(color, 64, 0).apply { - setOnClickListener { - persistInt(themeId) - dialog.dismiss() - callChangeListener(themeId) - } - } - addView(view) - } - - } - - dialog = MaterialAlertDialogBuilder(context).setTitle(title) - .setView(LinearLayout(context).apply { - gravity = Gravity.CENTER - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT - ) - addView(grid) - }) - .setNegativeButton(android.R.string.cancel, null) - .show() - } -} diff --git a/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt b/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt new file mode 100644 index 000000000..9ed695af2 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt @@ -0,0 +1,225 @@ +package moe.matsuri.nb4a.ui + +import android.content.Context +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.GridLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.content.res.ResourcesCompat +import androidx.core.content.res.TypedArrayUtils +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.setPadding +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.ktx.getColorAttr +import io.nekohasekai.sagernet.utils.Theme +import kotlin.math.roundToInt + +/** + * Two-tier theme picker. + * + * The preference opens a modern named list of full-fledged Material 3 themes + * (each row: preview swatch + name). A trailing "Classic colors…" row opens the + * legacy single-accent color grid (the original swatch-grid UX). + * + * Both paths persist the same int [Theme] id via [persistInt] so the rest of the + * app (Theme.getTheme / DataStore.appTheme) is unchanged. + */ +class ThemePickerPreference +@JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyle: Int = TypedArrayUtils.getAttr( + context, + androidx.preference.R.attr.editTextPreferenceStyle, + android.R.attr.editTextPreferenceStyle + ) +) : Preference( + context, attrs, defStyle +) { + + private var inited = false + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val widgetFrame = holder.findViewById(android.R.id.widget_frame) as LinearLayout + + if (!inited) { + inited = true + widgetFrame.addView( + nekoImageView(context.getColorAttr(R.attr.colorPrimary), 48, 0) + ) + widgetFrame.visibility = View.VISIBLE + } + } + + private fun nekoImageView(color: Int, sizeDp: Int, paddingDp: Int): ImageView { + val factor = context.resources.displayMetrics.density + val size = (sizeDp * factor).roundToInt() + val paddingSize = (paddingDp * factor).roundToInt() + + return ImageView(context).apply { + layoutParams = ViewGroup.LayoutParams(size, size) + setPadding(paddingSize) + setImageDrawable(nekoAtColor(resources, color)) + } + } + + private fun nekoAtColor(res: Resources, color: Int): Drawable { + val neko = ResourcesCompat.getDrawable( + res, R.drawable.ic_baseline_fiber_manual_record_24, null + )!! + DrawableCompat.setTint(neko.mutate(), color) + return neko + } + + private fun dp(value: Int): Int = + (value * context.resources.displayMetrics.density).roundToInt() + + override fun onClick() { + super.onClick() + showModernDialog() + } + + /** Modern named list: one row per [Theme.MODERN_THEMES] entry + a "Classic colors…" row. */ + private fun showModernDialog() { + lateinit var dialog: AlertDialog + + val container = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(8), dp(8), dp(8), dp(8)) + } + + for (info in Theme.MODERN_THEMES) { + container.addView( + buildRow( + name = context.getString(info.nameRes), + swatchColor = ResourcesCompat.getColor(context.resources, info.previewColor, context.theme), + ) { + select(info.id) + dialog.dismiss() + } + ) + } + + // Trailing entry: open the legacy single-accent color grid. + container.addView( + buildRow( + name = context.getString(R.string.theme_classic_colors), + swatchColor = null, + icon = R.drawable.ic_baseline_color_lens_24, + ) { + dialog.dismiss() + showClassicGrid() + } + ) + + dialog = MaterialAlertDialogBuilder(context) + .setTitle(title) + .setView(container) + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun buildRow( + name: String, + swatchColor: Int?, + icon: Int? = null, + onClick: () -> Unit, + ): View { + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + isClickable = true + isFocusable = true + setPadding(dp(16), dp(14), dp(16), dp(14)) + // Selectable item background for ripple feedback. + val outValue = android.util.TypedValue() + context.theme.resolveAttribute( + android.R.attr.selectableItemBackground, outValue, true + ) + setBackgroundResource(outValue.resourceId) + + val leading = when { + swatchColor != null -> nekoImageView(swatchColor, 28, 0) + icon != null -> ImageView(context).apply { + layoutParams = ViewGroup.LayoutParams(dp(28), dp(28)) + setImageDrawable( + ResourcesCompat.getDrawable(resources, icon, context.theme)?.also { + DrawableCompat.setTint( + it.mutate(), context.getColorAttr(android.R.attr.textColorPrimary) + ) + } + ) + } + else -> View(context).apply { + layoutParams = ViewGroup.LayoutParams(dp(28), dp(28)) + } + } + addView(leading) + + addView(TextView(context).apply { + text = name + setPadding(dp(16), 0, 0, 0) + textSize = 16f + setTextColor(context.getColorAttr(android.R.attr.textColorPrimary)) + layoutParams = LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f + ) + }) + + setOnClickListener { onClick() } + } + } + + /** Legacy single-accent color grid (the original swatch-grid UX). */ + private fun showClassicGrid() { + lateinit var dialog: AlertDialog + + val grid = GridLayout(context).apply { + columnCount = 4 + + val colors = context.resources.getIntArray(R.array.material_colors) + for ((index, color) in colors.withIndex()) { + // Swatch index+1 is the Theme id (see Theme.kt). Skip themes that + // are presented in the modern named list instead of the grid. + val themeId = index + 1 + if (Theme.MODERN_THEMES.any { it.id == themeId }) continue + + addView(nekoImageView(color, 64, 0).apply { + setOnClickListener { + select(themeId) + dialog.dismiss() + } + }) + } + } + + dialog = MaterialAlertDialogBuilder(context) + .setTitle(R.string.theme_classic_colors) + .setView(LinearLayout(context).apply { + gravity = Gravity.CENTER + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ) + addView(grid) + }) + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun select(themeId: Int) { + // Match the legacy swatch-grid ordering: persist first, then notify the + // change listener (SettingsPreferenceFragment applies/recreates). + persistInt(themeId) + callChangeListener(themeId) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bedec5863..d3df83dcf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,8 @@ Group About Theme + Dynamic (Material You) + Classic colors… Document Ungrouped Toggle diff --git a/app/src/main/res/xml/global_preferences.xml b/app/src/main/res/xml/global_preferences.xml index bd9d9e463..87d9320fc 100644 --- a/app/src/main/res/xml/global_preferences.xml +++ b/app/src/main/res/xml/global_preferences.xml @@ -8,7 +8,7 @@ app:key="isAutoConnect" app:summary="@string/auto_connect_summary" app:title="@string/auto_connect" /> - From fa9a271d8e2f8775c8b7ee9c43491d4fa93b507b Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:22:45 -0400 Subject: [PATCH 02/15] Harden theme picker: null-safe widget cast + accessibility Address CodeRabbit findings: - Use safe cast (as?) with early return for the widget_frame lookup. - Add contentDescription to modern list rows (theme name) and classic grid swatches (Color theme %d) for screen-reader support. --- .../main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt | 7 ++++++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt b/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt index 9ed695af2..832cf3444 100644 --- a/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt +++ b/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt @@ -50,7 +50,8 @@ class ThemePickerPreference override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) - val widgetFrame = holder.findViewById(android.R.id.widget_frame) as LinearLayout + val widgetFrame = holder.findViewById(android.R.id.widget_frame) as? LinearLayout + ?: return if (!inited) { inited = true @@ -147,6 +148,8 @@ class ThemePickerPreference android.R.attr.selectableItemBackground, outValue, true ) setBackgroundResource(outValue.resourceId) + // Announce the theme name to screen readers when the row is focused. + contentDescription = name val leading = when { swatchColor != null -> nekoImageView(swatchColor, 28, 0) @@ -195,6 +198,8 @@ class ThemePickerPreference if (Theme.MODERN_THEMES.any { it.id == themeId }) continue addView(nekoImageView(color, 64, 0).apply { + contentDescription = + context.getString(R.string.theme_classic_color_swatch, themeId) setOnClickListener { select(themeId) dialog.dismiss() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d3df83dcf..17b3f047e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Theme Dynamic (Material You) Classic colors… + Color theme %1$d Document Ungrouped Toggle From 37661e190d39e4003add5f033d1abfe65b9eacd6 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:31:24 -0400 Subject: [PATCH 03/15] Theme picker: scrollable modern list + robust swatch binding Address Greptile review: - Wrap the modern theme list in a ScrollView so it stays usable on compact screens as Theme.MODERN_THEMES grows. - Bind the preference swatch off the holder's actual view state (widgetFrame.childCount) instead of an instance flag, so a recycled/ re-inflated holder still gets its swatch. Drops the unused 'inited' field. --- .../moe/matsuri/nb4a/ui/ThemePickerPreference.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt b/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt index 832cf3444..c79cb44c2 100644 --- a/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt +++ b/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt @@ -10,6 +10,7 @@ import android.view.ViewGroup import android.widget.GridLayout import android.widget.ImageView import android.widget.LinearLayout +import android.widget.ScrollView import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.content.res.ResourcesCompat @@ -45,16 +46,16 @@ class ThemePickerPreference context, attrs, defStyle ) { - private var inited = false - override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val widgetFrame = holder.findViewById(android.R.id.widget_frame) as? LinearLayout ?: return - if (!inited) { - inited = true + // Key off the holder's actual view state, not an instance flag: the + // RecyclerView can recycle/re-inflate this row's holder, in which case a + // stale instance flag would leave the new frame without its swatch. + if (widgetFrame.childCount == 0) { widgetFrame.addView( nekoImageView(context.getColorAttr(R.attr.colorPrimary), 48, 0) ) @@ -125,7 +126,9 @@ class ThemePickerPreference dialog = MaterialAlertDialogBuilder(context) .setTitle(title) - .setView(container) + // Wrap in a ScrollView so the list stays usable on compact screens as + // Theme.MODERN_THEMES grows beyond the dialog's height. + .setView(ScrollView(context).apply { addView(container) }) .setNegativeButton(android.R.string.cancel, null) .show() } From 3bd385495743e67a4a9cc4e605fff950a8cc9c8f Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:47:15 -0400 Subject: [PATCH 04/15] Add Material 3 Dark High Contrast theme (OLED black) A full-fledged M3 dark-only theme on a true black (#000000) canvas: green-primary action accent (GitHub style) with a bright blue secondary for links/selection and high-contrast near-white text. - Theme.DARK_HIGH_CONTRAST (id 25) mapped to new Theme.SagerNet. DarkHighContrast styles (day declares the full dark canvas so it never flashes light; night repeats it as the active forced-night path). - Full M3 role overrides: surface/background/containers, surfaceVariant, outline/outlineVariant, secondary/tertiary, plus status semantics (green/red/yellow) and route/proxy blue. - Generalize the dark-only night-mode handling: selecting any theme in Theme.DARK_ONLY_THEMES (Dracula, Dark High Contrast) forces night mode on and restores the prior setting on exit. Storage key kept for compat. - Surfaced by name in the modern picker list (green preview swatch); excluded from the classic grid. - Picker select() now follows the standard Preference contract (notify-then-persist) so the fragment sees the true previous theme. --- .../java/io/nekohasekai/sagernet/Constants.kt | 6 +- .../sagernet/database/DataStore.kt | 5 +- .../sagernet/ui/SettingsPreferenceFragment.kt | 19 ++-- .../io/nekohasekai/sagernet/utils/Theme.kt | 12 +++ .../matsuri/nb4a/ui/ThemePickerPreference.kt | 11 ++- app/src/main/res/values-night/themes.xml | 85 +++++++++++++++++ app/src/main/res/values/colors.xml | 26 ++++++ app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/themes.xml | 93 +++++++++++++++++++ 9 files changed, 242 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt index 44ad8071f..84f0e76fd 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -14,8 +14,10 @@ object Key { const val APP_EXPERT = "isExpert" const val APP_THEME = "appTheme" const val NIGHT_THEME = "nightTheme" - // Remembers the user's night-mode setting before the dark-only Dracula theme - // forced it on, so it can be restored when switching to another theme. + // Remembers the user's night-mode setting before a dark-only theme (Dracula + // or Dark High Contrast) forced it on, so it can be restored when switching + // to another theme. Storage key kept as "nightThemeBeforeDracula" for + // backward compatibility with previously persisted values. const val NIGHT_THEME_BEFORE_DRACULA = "nightThemeBeforeDracula" const val APP_LANGUAGE = "appLanguage" const val SERVICE_MODE = "serviceMode" diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt index 38b72f84f..23b822c56 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -103,8 +103,9 @@ object DataStore : OnPreferenceDataStoreChangeListener { var isExpert by configurationStore.boolean(Key.APP_EXPERT) var appTheme by configurationStore.int(Key.APP_THEME) var nightTheme by configurationStore.stringToInt(Key.NIGHT_THEME) - // -1 = not set (no Dracula override active). Otherwise holds the night-mode - // value to restore when leaving the dark-only Dracula theme. + // -1 = not set (no dark-only-theme override active). Otherwise holds the + // night-mode value to restore when leaving a dark-only theme (Dracula or + // Dark High Contrast). Key name kept for backward compatibility. var nightThemeBeforeDracula by configurationStore.int(Key.NIGHT_THEME_BEFORE_DRACULA) { -1 } var appLanguage by configurationStore.string(Key.APP_LANGUAGE) { "" } var serviceMode by configurationStore.string(Key.SERVICE_MODE) { Key.MODE_VPN } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt index d54b10878..2251cb551 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt @@ -72,11 +72,14 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { } val themeId = newTheme as Int val previousTheme = DataStore.appTheme // still the old value at this point - // Dracula is a dark-only theme: force night mode on so its #282a36 - // canvas (values-night) takes effect instead of a washed-out light surface. - // Remember the prior night-mode setting so it can be restored on exit. - if (themeId == Theme.DRACULA) { - if (previousTheme != Theme.DRACULA && DataStore.nightTheme != 1) { + // Dark-only themes (Dracula, Dark High Contrast) only look right in + // night mode: force it on so their dark canvas (values-night) takes + // effect instead of a washed-out light surface. Remember the prior + // night-mode setting so it can be restored when leaving such a theme. + val enteringDarkOnly = themeId in Theme.DARK_ONLY_THEMES + val leavingDarkOnly = previousTheme in Theme.DARK_ONLY_THEMES + if (enteringDarkOnly) { + if (!leavingDarkOnly && DataStore.nightTheme != 1) { DataStore.nightThemeBeforeDracula = DataStore.nightTheme Theme.currentNightMode = 1 // nightTheme.value persists to configurationStore (same key as @@ -85,8 +88,8 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { nightTheme.value = "1" Theme.applyNightTheme() } - } else if (previousTheme == Theme.DRACULA) { - // Leaving Dracula: restore the night-mode setting we forced on. + } else if (leavingDarkOnly) { + // Leaving a dark-only theme: restore the night-mode setting we forced on. val restore = DataStore.nightThemeBeforeDracula if (restore != -1) { DataStore.nightThemeBeforeDracula = -1 @@ -107,7 +110,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { nightTheme.setOnPreferenceChangeListener { _, newTheme -> Theme.currentNightMode = (newTheme as String).toInt() // A manual night-mode change takes precedence: drop any pending - // Dracula restore so we don't override the user's choice later. + // dark-only-theme restore so we don't override the user's choice later. DataStore.nightThemeBeforeDracula = -1 Theme.applyNightTheme() true diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt index 9880e556a..be1ba3389 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt @@ -33,6 +33,15 @@ object Theme { const val VERDANT_MINT = 22 const val DRACULA = 23 const val DYNAMIC = 24 + const val DARK_HIGH_CONTRAST = 25 + + /** + * Themes that only make sense in dark mode: selecting one forces night mode + * on so its dark canvas (values-night) takes effect, and the prior night + * setting is restored on exit (see SettingsPreferenceFragment). Dracula was + * the first such theme; Dark High Contrast (OLED black) is dark-only too. + */ + val DARK_ONLY_THEMES = setOf(DRACULA, DARK_HIGH_CONTRAST) private fun defaultTheme() = PINK_SSR @@ -51,6 +60,7 @@ object Theme { * Order here is the display order in the dialog. */ val MODERN_THEMES: List = listOf( + ThemeInfo(DARK_HIGH_CONTRAST, R.string.theme_dark_high_contrast, R.color.dhc_primary), ThemeInfo(DYNAMIC, R.string.theme_dynamic, R.color.color_dynamic_swatch), ) @@ -95,6 +105,7 @@ object Theme { BLACK -> R.style.Theme_SagerNet_Black VERDANT_MINT -> R.style.Theme_SagerNet_VerdantMint DRACULA -> R.style.Theme_SagerNet_Dracula + DARK_HIGH_CONTRAST -> R.style.Theme_SagerNet_DarkHighContrast DYNAMIC -> R.style.Theme_SagerNet else -> getTheme(defaultTheme()) } @@ -125,6 +136,7 @@ object Theme { BLACK -> R.style.Theme_SagerNet_Dialog_Black VERDANT_MINT -> R.style.Theme_SagerNet_Dialog_VerdantMint DRACULA -> R.style.Theme_SagerNet_Dialog_Dracula + DARK_HIGH_CONTRAST -> R.style.Theme_SagerNet_Dialog_DarkHighContrast DYNAMIC -> R.style.Theme_SagerNet_Dialog else -> getDialogTheme(defaultTheme()) } diff --git a/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt b/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt index c79cb44c2..841a6d473 100644 --- a/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt +++ b/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt @@ -225,9 +225,12 @@ class ThemePickerPreference } private fun select(themeId: Int) { - // Match the legacy swatch-grid ordering: persist first, then notify the - // change listener (SettingsPreferenceFragment applies/recreates). - persistInt(themeId) - callChangeListener(themeId) + // Notify the change listener first (while DataStore.appTheme still holds the + // previous theme, which SettingsPreferenceFragment reads to drive dark-only + // night-mode handling), then persist if accepted. This is the standard + // Preference contract; the listener always returns true here. + if (callChangeListener(themeId)) { + persistInt(themeId) + } } } diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index c1abd02a6..a0080db3c 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -87,4 +87,89 @@ @color/color_dracula_yellow @color/color_dracula_cyan + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 926a2500f..16c6bf11a 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -374,4 +374,30 @@ #343746 #F8F8F2 + + #3FB950 + #2EA043 + #0A0A0A + #71B7FF + #4493F8 + #000000 + #0D0D0D + #161616 + #1C1C1C + #F0F6FC + #C9D1D9 + #3D444D + #21262D + #3FB950 + #F85149 + #D29922 + #1C1C1C + #2EA043 + #0D0D0D + \ 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 17b3f047e..917b33cd8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,6 +15,7 @@ About Theme Dynamic (Material You) + Dark High Contrast Classic colors… Color theme %1$d Document diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 793bc7eaa..fed440270 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -738,6 +738,61 @@ ?attr/colorAccent + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 81f843155..a4ef09f4a 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -49,9 +49,10 @@ - + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 16c6bf11a..184f51c29 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -400,4 +400,114 @@ #2EA043 #0D0D0D + + + + #BD93F9 + #6272A4 + #282A36 + #FF79C6 + #8BE9FD + #282A36 + #343746 + #3C3F52 + #44475A + #F8F8F2 + #C9CEE3 + #6272A4 + #3C3F52 + #50FA7B + #FF5555 + #F1FA8C + #44475A + #6272A4 + #343746 + + + #88C0D0 + #5E81AC + #2E3440 + #81A1C1 + #81A1C1 + #2E3440 + #3B4252 + #434C5E + #434C5E + #ECEFF4 + #D8DEE9 + #4C566A + #3B4252 + #A3BE8C + #BF616A + #EBCB8B + #434C5E + #5E81AC + #3B4252 + + + #A6E22E + #75A813 + #272822 + #66D9EF + #66D9EF + #272822 + #31322B + #3A3B33 + #3E3D32 + #F8F8F2 + #CFCFC2 + #75715E + #3A3B33 + #A6E22E + #F92672 + #E6DB74 + #3E3D32 + #75715E + #31322B + + + #FFCC66 + #FFA759 + #1F2430 + #5CCFE6 + #5CCFE6 + #1F2430 + #232834 + #2A303C + #2D3440 + #E6E1CF + #B8B5A5 + #54596B + #2A303C + #BAE67E + #FF6666 + #FFD580 + #2D3440 + #FFA759 + #232834 + + + #CBA6F7 + #B4A0E5 + #1E1E2E + #89B4FA + #89B4FA + #1E1E2E + #28283D + #313244 + #45475A + #CDD6F4 + #BAC2DE + #6C7086 + #313244 + #A6E3A1 + #F38BA8 + #F9E2AF + #45475A + #6C7086 + #28283D + \ 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 917b33cd8..27ed8e5b7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,11 @@ Theme Dynamic (Material You) Dark High Contrast + Dracula + Nord + Monokai + Ayu + Catppuccin Classic colors… Color theme %1$d Document diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 9e1897547..c7babbb45 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -33,6 +33,7 @@ ?colorAccent ?colorPrimaryDark @color/material_pink_500 + @color/material_pink_500 @color/material_pink_700 @color/material_pink_accent_200 @color/white @@ -40,8 +41,6 @@ @color/material_pink_100 @color/material_pink_300 @color/card_elevated_pink - - ?attr/colorPrimary - ?attr/colorPrimary + + + + + + + + + + + + + + + + + + + + + + @@ -485,7 +485,7 @@ @color/draculam3_outline @color/draculam3_outline_variant @color/draculam3_green - @color/draculam3_on_surface + @color/draculam3_yellow @color/draculam3_green @color/draculam3_red @color/draculam3_yellow diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 56dc27ac9..86f878c11 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -868,12 +868,12 @@ @color/dhc_red @color/dhc_yellow @color/dhc_red - @color/dhc_accent + @color/dhc_on_surface_variant @color/dhc_on_primary @color/dhc_on_primary - @color/dhc_accent + @color/dhc_on_surface_variant @color/dhc_yellow @color/dhc_blue ?attr/colorPrimary @@ -919,7 +919,7 @@ @color/draculam3_primary @color/draculam3_primary @color/draculam3_green - @color/draculam3_on_surface + @color/draculam3_yellow @color/draculam3_green @color/draculam3_red @color/draculam3_yellow @@ -1239,10 +1239,10 @@ @color/dhc_red @color/dhc_yellow @color/dhc_red - @color/dhc_accent + @color/dhc_on_surface_variant @color/dhc_on_primary @color/dhc_on_primary - @color/dhc_accent + @color/dhc_on_surface_variant @color/dhc_yellow @color/dhc_blue @@ -1275,7 +1275,7 @@ @color/draculam3_outline @color/draculam3_outline_variant @color/draculam3_green - @color/draculam3_on_surface + @color/draculam3_yellow @color/draculam3_green @color/draculam3_red @color/draculam3_yellow From 1a1b9762f54a594e961000e49ffee7e4deab1031 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:34:37 -0400 Subject: [PATCH 09/15] BottomAppBar variants: inherit base style explicitly (CR) Give the modern themes' Widget.SagerNet.BottomAppBar.* variants an explicit parent=Widget.SagerNet.BottomAppBar so they keep the shared cradle/alignment/elevation config and only override backgroundTint, rather than relying on implicit dotted-name inheritance. --- app/src/main/res/values/themes.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 86f878c11..4449b525f 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -251,23 +251,23 @@ - - - - - - From a5d9213dcaadf2079b2833ec9af454d9bcd09496 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:55:36 -0400 Subject: [PATCH 10/15] DHC green accent, Legacy colors rename, progress dialog padding Address review feedback: - Dark High Contrast: use green (colorPrimary) for colorAccent/colorSecondary so section headers (App Settings / Route Settings) and switch toggles are green, matching the green identity (and the theme's green picker swatch), instead of the previously confusing blue. Blue (colorTertiary / route proxy / links) stays reserved for links. - Rename the picker's 'Classic colors...' entry to 'Legacy colors...'. - Add top padding + spacing above the connection-test progress dialog's loading circle so it isn't flush against the dialog's top edge. Verified on Android (DHC: green header + green toggle). --- app/src/main/res/layout/layout_progress_list.xml | 5 ++++- app/src/main/res/values-night/themes.xml | 12 ++++++------ app/src/main/res/values/strings.xml | 2 +- app/src/main/res/values/themes.xml | 12 ++++++------ 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/src/main/res/layout/layout_progress_list.xml b/app/src/main/res/layout/layout_progress_list.xml index a364ebd01..e6bfc9e6e 100644 --- a/app/src/main/res/layout/layout_progress_list.xml +++ b/app/src/main/res/layout/layout_progress_list.xml @@ -3,13 +3,16 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical"> + android:orientation="vertical" + android:paddingTop="24dp" + android:paddingBottom="8dp"> diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index cfb0a6a00..91d5f83b0 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -97,7 +97,7 @@ @color/dhc_primary @color/dhc_background @color/dhc_primary_dark - @color/dhc_accent + @color/dhc_primary @color/dhc_on_primary @color/dhc_100 @color/dhc_300 @@ -117,7 +117,7 @@ @color/dhc_primary @color/dhc_container @color/dhc_on_surface - @color/dhc_accent + @color/dhc_primary @color/dhc_container @color/dhc_on_surface @color/dhc_blue @@ -127,8 +127,8 @@ @color/dhc_on_surface_variant @color/dhc_outline @color/dhc_outline_variant - @color/dhc_accent - @color/dhc_accent + @color/dhc_primary + @color/dhc_primary @color/dhc_primary @color/dhc_primary @color/dhc_green @@ -421,7 +421,7 @@ @color/dhc_primary @color/dhc_background @color/dhc_primary_dark - @color/dhc_accent + @color/dhc_primary @color/dhc_on_primary @color/dhc_100 @color/dhc_300 @@ -433,7 +433,7 @@ @color/dhc_on_surface @color/dhc_container @color/dhc_on_surface - @color/dhc_accent + @color/dhc_primary @color/dhc_container @color/dhc_on_surface @color/dhc_blue diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27ed8e5b7..8bdae08fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,7 +21,7 @@ Monokai Ayu Catppuccin - Classic colors… + Legacy colors… Color theme %1$d Document Ungrouped diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 4449b525f..f4acaa198 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -818,7 +818,7 @@ @color/dhc_primary @color/dhc_background @color/dhc_primary_dark - @color/dhc_accent + @color/dhc_primary @color/dhc_on_primary @color/dhc_100 @color/dhc_300 @@ -845,7 +845,7 @@ @color/dhc_container @color/dhc_on_surface - @color/dhc_accent + @color/dhc_primary @color/dhc_container @color/dhc_on_surface @color/dhc_blue @@ -858,8 +858,8 @@ - @color/dhc_accent - @color/dhc_accent + @color/dhc_primary + @color/dhc_primary @color/dhc_primary @color/dhc_primary @color/dhc_green @@ -1211,7 +1211,7 @@ @color/dhc_primary @color/dhc_background @color/dhc_primary_dark - @color/dhc_accent + @color/dhc_primary @color/dhc_on_primary @color/dhc_100 @color/dhc_300 @@ -1223,7 +1223,7 @@ @color/dhc_on_surface @color/dhc_container @color/dhc_on_surface - @color/dhc_accent + @color/dhc_primary @color/dhc_container @color/dhc_on_surface @color/dhc_blue From cdf6a51447b3f180dae6f5bc0d8ecf83d7aa824f Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:01:38 -0400 Subject: [PATCH 11/15] Complete M3 migration: convert remaining AppCompat/M2 widgets Replace the last non-M3 themed widgets so the app is fully Material 3: - SwitchCompat -> MaterialSwitch in layout_route_item and layout_apps_item. - CheckBox -> MaterialCheckBox in layout_backup and layout_import (6 total). - androidx.appcompat.widget.Toolbar -> MaterialToolbar in webdav settings. All are API-compatible subclasses, so ViewBinding/findViewById and the existing isChecked/listener calls are unchanged. Only AppCompatImageView remains, which is the idiomatic M3 image view (not an M2 themed widget). Verified on Android: route/apps MaterialSwitch toggles render M3 (green on Dark High Contrast), no crashes. --- app/src/main/res/layout/layout_apps_item.xml | 2 +- app/src/main/res/layout/layout_backup.xml | 6 +++--- app/src/main/res/layout/layout_import.xml | 6 +++--- app/src/main/res/layout/layout_route_item.xml | 2 +- app/src/main/res/layout/layout_webdav_settings.xml | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/res/layout/layout_apps_item.xml b/app/src/main/res/layout/layout_apps_item.xml index b9c31af75..79967a57c 100644 --- a/app/src/main/res/layout/layout_apps_item.xml +++ b/app/src/main/res/layout/layout_apps_item.xml @@ -57,7 +57,7 @@ - - - - - - - - - Date: Mon, 22 Jun 2026 16:08:14 -0400 Subject: [PATCH 12/15] Clarify inset comment terminology (CR): 'bottom bar' not 'status bar' --- app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt index 0d85a35f3..773a89c2e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt @@ -60,7 +60,7 @@ abstract class ThemedActivity : AppCompatActivity { findViewById(R.id.appbar)?.apply { updatePadding(top = bars.top) } - // Lift the bottom status bar (and the FAB docked into it, plus the FAB's + // Lift the bottom bar (and the FAB docked into it, plus the FAB's // progress ring anchored to the FAB) above the navigation bar so the // ring isn't clipped by the system inset under edge-to-edge. findViewById(R.id.stats)?.apply { From 5c08261e1c6ee7b0b3526bb521674c936137bc15 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:28:14 -0400 Subject: [PATCH 13/15] DHC ringed swatch + list bottom padding to clear docked FAB/stats - Theme picker: Dark High Contrast preview swatch is now a black circle with a white circumference ring (via ThemeInfo.ringColor + a GradientDrawable oval) so it reads as 'OLED black', not 'a green theme'. Other modern themes keep their solid accent dot. - Profile + route lists: increase bottom padding to 96dp (with clipToPadding=false) so the last card clears the docked FAB and the bottom stats bar instead of being hidden behind them when scrolled to the bottom. Verified on Android (DHC swatch ring; last card clears FAB at scroll end). --- .../io/nekohasekai/sagernet/utils/Theme.kt | 25 ++++++++++++++--- .../matsuri/nb4a/ui/ThemePickerPreference.kt | 27 +++++++++++++++++++ .../main/res/layout/layout_profile_list.xml | 2 +- app/src/main/res/layout/layout_route.xml | 6 ++++- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt index 2d07bcd05..6e00ca68a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt @@ -59,15 +59,34 @@ object Theme { * @param nameRes display name string resource * @param previewColor color resource for the preview swatch shown next to the name */ - data class ThemeInfo(val id: Int, val nameRes: Int, val previewColor: Int) + /** + * Metadata for a theme shown in the modern named picker. + * + * @param id one of the Theme int constants above (persisted to appTheme) + * @param nameRes display name string resource + * @param previewColor fill color for the preview swatch shown next to the name + * @param ringColor optional circumference-ring color; when non-zero the swatch + * is drawn as [previewColor] fill + a thin [ringColor] frame. + * Used by Dark High Contrast (black fill + white ring) so its + * OLED-black identity doesn't read as "a green theme". + */ + data class ThemeInfo( + val id: Int, + val nameRes: Int, + val previewColor: Int, + val ringColor: Int = 0, + ) /** * Modern, full-fledged M3 themes presented by name in the picker dialog. - * The legacy single-accent palettes stay behind the "Classic colors…" grid. + * The legacy single-accent palettes stay behind the "Legacy colors…" grid. * Order here is the display order in the dialog. */ val MODERN_THEMES: List = listOf( - ThemeInfo(DARK_HIGH_CONTRAST, R.string.theme_dark_high_contrast, R.color.dhc_primary), + ThemeInfo( + DARK_HIGH_CONTRAST, R.string.theme_dark_high_contrast, + R.color.dhc_background, R.color.white, + ), ThemeInfo(DRACULA_M3, R.string.theme_dracula_m3, R.color.draculam3_primary), ThemeInfo(NORD, R.string.theme_nord, R.color.nord_primary), ThemeInfo(MONOKAI, R.string.theme_monokai, R.color.monokai_primary), diff --git a/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt b/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt index 841a6d473..50b95ac68 100644 --- a/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt +++ b/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt @@ -3,6 +3,7 @@ package moe.matsuri.nb4a.ui import android.content.Context import android.content.res.Resources import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable import android.util.AttributeSet import android.view.Gravity import android.view.View @@ -83,6 +84,25 @@ class ThemePickerPreference return neko } + /** + * A circular swatch with a [fillColor] interior and a thin [ringColor] + * circumference ring. Used for themes (e.g. Dark High Contrast) whose solid + * accent dot would misrepresent the theme — a black fill + white ring reads + * as "OLED black" rather than "a green theme". + */ + private fun ringedSwatchView(fillColor: Int, ringColor: Int, sizeDp: Int): ImageView { + val size = dp(sizeDp) + val ring = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(fillColor) + setStroke(dp(2), ringColor) + } + return ImageView(context).apply { + layoutParams = ViewGroup.LayoutParams(size, size) + setImageDrawable(ring) + } + } + private fun dp(value: Int): Int = (value * context.resources.displayMetrics.density).roundToInt() @@ -101,10 +121,14 @@ class ThemePickerPreference } for (info in Theme.MODERN_THEMES) { + val ring = if (info.ringColor != 0) { + ResourcesCompat.getColor(context.resources, info.ringColor, context.theme) + } else null container.addView( buildRow( name = context.getString(info.nameRes), swatchColor = ResourcesCompat.getColor(context.resources, info.previewColor, context.theme), + ringColor = ring, ) { select(info.id) dialog.dismiss() @@ -136,6 +160,7 @@ class ThemePickerPreference private fun buildRow( name: String, swatchColor: Int?, + ringColor: Int? = null, icon: Int? = null, onClick: () -> Unit, ): View { @@ -155,6 +180,8 @@ class ThemePickerPreference contentDescription = name val leading = when { + swatchColor != null && ringColor != null -> + ringedSwatchView(swatchColor, ringColor, 28) swatchColor != null -> nekoImageView(swatchColor, 28, 0) icon != null -> ImageView(context).apply { layoutParams = ViewGroup.LayoutParams(dp(28), dp(28)) diff --git a/app/src/main/res/layout/layout_profile_list.xml b/app/src/main/res/layout/layout_profile_list.xml index 9f8a4b5a7..571cc9016 100644 --- a/app/src/main/res/layout/layout_profile_list.xml +++ b/app/src/main/res/layout/layout_profile_list.xml @@ -10,7 +10,7 @@ android:paddingLeft="4dp" android:paddingTop="4dp" android:paddingRight="4dp" - android:paddingBottom="48dp" + android:paddingBottom="96dp" android:scrollbarSize="0dp" app:fastScrollAutoHide="true" app:fastScrollThumbColor="?colorPrimary" diff --git a/app/src/main/res/layout/layout_route.xml b/app/src/main/res/layout/layout_route.xml index 971231f36..5ade2f1b3 100644 --- a/app/src/main/res/layout/layout_route.xml +++ b/app/src/main/res/layout/layout_route.xml @@ -18,7 +18,11 @@ android:id="@+id/route_list" android:layout_width="match_parent" android:layout_height="match_parent" - android:padding="4dp" + android:clipToPadding="false" + android:paddingLeft="4dp" + android:paddingTop="4dp" + android:paddingRight="4dp" + android:paddingBottom="96dp" android:scrollbarSize="0dp" app:fastScrollAutoHide="true" app:fastScrollThumbColor="?colorPrimary" From f18ad9a069160a55cff3f94fb6bd36fad32b2cff Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:32:28 -0400 Subject: [PATCH 14/15] Increase list bottom padding to 150dp to fully clear docked FAB/stats 96dp wasn't enough: when connected, the pinned stats bar (~115dp) plus the FAB cradle overhang (~40dp) overlapped the last card. Measured the FAB top sitting ~431px above the screen bottom on the test device, so bump the profile + route list bottom padding to 150dp (with clipToPadding=false) so the last card always clears the FAB and stats bar in both connected and disconnected states. Verified on Android: last card fully visible above the FAB at scroll end. --- app/src/main/res/layout/layout_profile_list.xml | 2 +- app/src/main/res/layout/layout_route.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/layout_profile_list.xml b/app/src/main/res/layout/layout_profile_list.xml index 571cc9016..16d3e8c44 100644 --- a/app/src/main/res/layout/layout_profile_list.xml +++ b/app/src/main/res/layout/layout_profile_list.xml @@ -10,7 +10,7 @@ android:paddingLeft="4dp" android:paddingTop="4dp" android:paddingRight="4dp" - android:paddingBottom="96dp" + android:paddingBottom="150dp" android:scrollbarSize="0dp" app:fastScrollAutoHide="true" app:fastScrollThumbColor="?colorPrimary" diff --git a/app/src/main/res/layout/layout_route.xml b/app/src/main/res/layout/layout_route.xml index 5ade2f1b3..c13d2d693 100644 --- a/app/src/main/res/layout/layout_route.xml +++ b/app/src/main/res/layout/layout_route.xml @@ -22,7 +22,7 @@ android:paddingLeft="4dp" android:paddingTop="4dp" android:paddingRight="4dp" - android:paddingBottom="96dp" + android:paddingBottom="150dp" android:scrollbarSize="0dp" app:fastScrollAutoHide="true" app:fastScrollThumbColor="?colorPrimary" From 16564770954051c5fce36cb75b291b8ef09a6cb4 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:46:13 -0400 Subject: [PATCH 15/15] Hide FAB on scroll + 168dp list padding so it never overlaps cards - Add a RecyclerView scroll listener on the profile list: hide the docked FAB on downward scroll, show it on upward scroll or when the list settles (idle). Only acts in stable Stopped/Connected states so it doesn't fight the FAB's connect/disconnect animation. - Bump profile + route list bottom padding to 168dp (FAB occupies ~157dp at 440dpi) so the last card always rests above the docked FAB and the pinned stats bar, in both connected and disconnected states. Verified on Android (Dracula-M3, connected): last card clears the FAB at the scroll extent; FAB tucks away while scrolling. --- .../sagernet/ui/ConfigurationFragment.kt | 23 +++++++++++++++++++ .../main/res/layout/layout_profile_list.xml | 2 +- app/src/main/res/layout/layout_route.xml | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 589898ea4..d3fc4eecd 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -1396,6 +1396,29 @@ class ConfigurationFragment @JvmOverloads constructor( configurationListView.adapter = adapter configurationListView.setItemViewCacheSize(20) + // Hide the docked FAB while scrolling down (so it never overlaps the + // bottom card) and bring it back on upward scroll or when the list + // settles. Mirrors the stats bar's hideOnScroll behavior. Only act in + // stable states (Stopped/Connected) so we don't fight the FAB's own + // show/hide animation during Connecting/Stopping. + configurationListView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + private fun stableState(): Boolean = DataStore.serviceState.let { + it == BaseService.State.Stopped || it == BaseService.State.Connected + } + + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (!stableState()) return + val fab = (activity as? MainActivity)?.binding?.fab ?: return + if (dy > 4) fab.hide() else if (dy < -4) fab.show() + } + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + if (newState == RecyclerView.SCROLL_STATE_IDLE && stableState()) { + (activity as? MainActivity)?.binding?.fab?.show() + } + } + }) + if (!select) { undoManager = UndoSnackbarManager(activity as MainActivity, adapter!!) setupItemTouchHelper() diff --git a/app/src/main/res/layout/layout_profile_list.xml b/app/src/main/res/layout/layout_profile_list.xml index 16d3e8c44..04acc1496 100644 --- a/app/src/main/res/layout/layout_profile_list.xml +++ b/app/src/main/res/layout/layout_profile_list.xml @@ -10,7 +10,7 @@ android:paddingLeft="4dp" android:paddingTop="4dp" android:paddingRight="4dp" - android:paddingBottom="150dp" + android:paddingBottom="168dp" android:scrollbarSize="0dp" app:fastScrollAutoHide="true" app:fastScrollThumbColor="?colorPrimary" diff --git a/app/src/main/res/layout/layout_route.xml b/app/src/main/res/layout/layout_route.xml index c13d2d693..a2bce33ba 100644 --- a/app/src/main/res/layout/layout_route.xml +++ b/app/src/main/res/layout/layout_route.xml @@ -22,7 +22,7 @@ android:paddingLeft="4dp" android:paddingTop="4dp" android:paddingRight="4dp" - android:paddingBottom="150dp" + android:paddingBottom="168dp" android:scrollbarSize="0dp" app:fastScrollAutoHide="true" app:fastScrollThumbColor="?colorPrimary"