Skip to content

Commit e7a1cea

Browse files
authored
Merge pull request #57: Two-tier theme picker + Material 3 dark themes
Two-tier theme picker (modern named list + Legacy colors grid), six full M3 dark themes (Dark High Contrast, Dracula, Nord, Monokai, Ayu, Catppuccin), completed M3 widget migration, and FAB/list overlap fixes. Verified on Android; CI green.
2 parents 6b9c51d + 1656477 commit e7a1cea

29 files changed

Lines changed: 1813 additions & 158 deletions

app/src/main/java/io/nekohasekai/sagernet/Constants.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ object Key {
1414
const val APP_EXPERT = "isExpert"
1515
const val APP_THEME = "appTheme"
1616
const val NIGHT_THEME = "nightTheme"
17-
// Remembers the user's night-mode setting before the dark-only Dracula theme
18-
// forced it on, so it can be restored when switching to another theme.
17+
// Remembers the user's night-mode setting before a dark-only theme (Dracula
18+
// or Dark High Contrast) forced it on, so it can be restored when switching
19+
// to another theme. Storage key kept as "nightThemeBeforeDracula" for
20+
// backward compatibility with previously persisted values.
1921
const val NIGHT_THEME_BEFORE_DRACULA = "nightThemeBeforeDracula"
2022
const val APP_LANGUAGE = "appLanguage"
2123
const val SERVICE_MODE = "serviceMode"

app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,9 @@ object DataStore : OnPreferenceDataStoreChangeListener {
103103
var isExpert by configurationStore.boolean(Key.APP_EXPERT)
104104
var appTheme by configurationStore.int(Key.APP_THEME)
105105
var nightTheme by configurationStore.stringToInt(Key.NIGHT_THEME)
106-
// -1 = not set (no Dracula override active). Otherwise holds the night-mode
107-
// value to restore when leaving the dark-only Dracula theme.
106+
// -1 = not set (no dark-only-theme override active). Otherwise holds the
107+
// night-mode value to restore when leaving a dark-only theme (Dracula or
108+
// Dark High Contrast). Key name kept for backward compatibility.
108109
var nightThemeBeforeDracula by configurationStore.int(Key.NIGHT_THEME_BEFORE_DRACULA) { -1 }
109110
var appLanguage by configurationStore.string(Key.APP_LANGUAGE) { "" }
110111
var serviceMode by configurationStore.string(Key.SERVICE_MODE) { Key.MODE_VPN }

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,29 @@ class ConfigurationFragment @JvmOverloads constructor(
13961396
configurationListView.adapter = adapter
13971397
configurationListView.setItemViewCacheSize(20)
13981398

1399+
// Hide the docked FAB while scrolling down (so it never overlaps the
1400+
// bottom card) and bring it back on upward scroll or when the list
1401+
// settles. Mirrors the stats bar's hideOnScroll behavior. Only act in
1402+
// stable states (Stopped/Connected) so we don't fight the FAB's own
1403+
// show/hide animation during Connecting/Stopping.
1404+
configurationListView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
1405+
private fun stableState(): Boolean = DataStore.serviceState.let {
1406+
it == BaseService.State.Stopped || it == BaseService.State.Connected
1407+
}
1408+
1409+
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
1410+
if (!stableState()) return
1411+
val fab = (activity as? MainActivity)?.binding?.fab ?: return
1412+
if (dy > 4) fab.hide() else if (dy < -4) fab.show()
1413+
}
1414+
1415+
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
1416+
if (newState == RecyclerView.SCROLL_STATE_IDLE && stableState()) {
1417+
(activity as? MainActivity)?.binding?.fab?.show()
1418+
}
1419+
}
1420+
})
1421+
13991422
if (!select) {
14001423
undoManager = UndoSnackbarManager(activity as MainActivity, adapter!!)
14011424
setupItemTouchHelper()

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,19 +64,22 @@ 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) {
7171
SagerNet.reloadService()
7272
}
7373
val themeId = newTheme as Int
7474
val previousTheme = DataStore.appTheme // still the old value at this point
75-
// Dracula is a dark-only theme: force night mode on so its #282a36
76-
// canvas (values-night) takes effect instead of a washed-out light surface.
77-
// Remember the prior night-mode setting so it can be restored on exit.
78-
if (themeId == Theme.DRACULA) {
79-
if (previousTheme != Theme.DRACULA && DataStore.nightTheme != 1) {
75+
// Dark-only themes (Dracula, Dark High Contrast) only look right in
76+
// night mode: force it on so their dark canvas (values-night) takes
77+
// effect instead of a washed-out light surface. Remember the prior
78+
// night-mode setting so it can be restored when leaving such a theme.
79+
val enteringDarkOnly = themeId in Theme.DARK_ONLY_THEMES
80+
val leavingDarkOnly = previousTheme in Theme.DARK_ONLY_THEMES
81+
if (enteringDarkOnly) {
82+
if (!leavingDarkOnly && DataStore.nightTheme != 1) {
8083
DataStore.nightThemeBeforeDracula = DataStore.nightTheme
8184
Theme.currentNightMode = 1
8285
// nightTheme.value persists to configurationStore (same key as
@@ -85,8 +88,8 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
8588
nightTheme.value = "1"
8689
Theme.applyNightTheme()
8790
}
88-
} else if (previousTheme == Theme.DRACULA) {
89-
// Leaving Dracula: restore the night-mode setting we forced on.
91+
} else if (leavingDarkOnly) {
92+
// Leaving a dark-only theme: restore the night-mode setting we forced on.
9093
val restore = DataStore.nightThemeBeforeDracula
9194
if (restore != -1) {
9295
DataStore.nightThemeBeforeDracula = -1
@@ -107,7 +110,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
107110
nightTheme.setOnPreferenceChangeListener { _, newTheme ->
108111
Theme.currentNightMode = (newTheme as String).toInt()
109112
// A manual night-mode change takes precedence: drop any pending
110-
// Dracula restore so we don't override the user's choice later.
113+
// dark-only-theme restore so we don't override the user's choice later.
111114
DataStore.nightThemeBeforeDracula = -1
112115
Theme.applyNightTheme()
113116
true

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package io.nekohasekai.sagernet.ui
33
import android.content.res.Configuration
44
import android.os.Build
55
import android.os.Bundle
6+
import android.view.View
67
import android.widget.TextView
78
import androidx.annotation.StringRes
89
import androidx.appcompat.app.AppCompatActivity
@@ -59,6 +60,12 @@ abstract class ThemedActivity : AppCompatActivity {
5960
findViewById<AppBarLayout>(R.id.appbar)?.apply {
6061
updatePadding(top = bars.top)
6162
}
63+
// Lift the bottom bar (and the FAB docked into it, plus the FAB's
64+
// progress ring anchored to the FAB) above the navigation bar so the
65+
// ring isn't clipped by the system inset under edge-to-edge.
66+
findViewById<View>(R.id.stats)?.apply {
67+
updatePadding(bottom = bars.bottom)
68+
}
6269
insets
6370
}
6471
}

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,68 @@ object Theme {
3333
const val VERDANT_MINT = 22
3434
const val DRACULA = 23
3535
const val DYNAMIC = 24
36+
const val DARK_HIGH_CONTRAST = 25
37+
const val DRACULA_M3 = 26
38+
const val NORD = 27
39+
const val MONOKAI = 28
40+
const val AYU = 29
41+
const val CATPPUCCIN = 30
42+
43+
/**
44+
* Themes that only make sense in dark mode: selecting one forces night mode
45+
* on so its dark canvas (values-night) takes effect, and the prior night
46+
* setting is restored on exit (see SettingsPreferenceFragment). Dracula was
47+
* the first such theme; the modern M3 themes are dark-only too.
48+
*/
49+
val DARK_ONLY_THEMES = setOf(
50+
DRACULA, DARK_HIGH_CONTRAST, DRACULA_M3, NORD, MONOKAI, AYU, CATPPUCCIN,
51+
)
3652

3753
private fun defaultTheme() = PINK_SSR
3854

55+
/**
56+
* Metadata for a theme shown in the modern named picker.
57+
*
58+
* @param id one of the Theme int constants above (persisted to appTheme)
59+
* @param nameRes display name string resource
60+
* @param previewColor color resource for the preview swatch shown next to the name
61+
*/
62+
/**
63+
* Metadata for a theme shown in the modern named picker.
64+
*
65+
* @param id one of the Theme int constants above (persisted to appTheme)
66+
* @param nameRes display name string resource
67+
* @param previewColor fill color for the preview swatch shown next to the name
68+
* @param ringColor optional circumference-ring color; when non-zero the swatch
69+
* is drawn as [previewColor] fill + a thin [ringColor] frame.
70+
* Used by Dark High Contrast (black fill + white ring) so its
71+
* OLED-black identity doesn't read as "a green theme".
72+
*/
73+
data class ThemeInfo(
74+
val id: Int,
75+
val nameRes: Int,
76+
val previewColor: Int,
77+
val ringColor: Int = 0,
78+
)
79+
80+
/**
81+
* Modern, full-fledged M3 themes presented by name in the picker dialog.
82+
* The legacy single-accent palettes stay behind the "Legacy colors…" grid.
83+
* Order here is the display order in the dialog.
84+
*/
85+
val MODERN_THEMES: List<ThemeInfo> = listOf(
86+
ThemeInfo(
87+
DARK_HIGH_CONTRAST, R.string.theme_dark_high_contrast,
88+
R.color.dhc_background, R.color.white,
89+
),
90+
ThemeInfo(DRACULA_M3, R.string.theme_dracula_m3, R.color.draculam3_primary),
91+
ThemeInfo(NORD, R.string.theme_nord, R.color.nord_primary),
92+
ThemeInfo(MONOKAI, R.string.theme_monokai, R.color.monokai_primary),
93+
ThemeInfo(AYU, R.string.theme_ayu, R.color.ayu_primary),
94+
ThemeInfo(CATPPUCCIN, R.string.theme_catppuccin, R.color.catppuccin_primary),
95+
ThemeInfo(DYNAMIC, R.string.theme_dynamic, R.color.color_dynamic_swatch),
96+
)
97+
3998
fun apply(context: Context) {
4099
context.setTheme(getTheme())
41100
}
@@ -77,6 +136,12 @@ object Theme {
77136
BLACK -> R.style.Theme_SagerNet_Black
78137
VERDANT_MINT -> R.style.Theme_SagerNet_VerdantMint
79138
DRACULA -> R.style.Theme_SagerNet_Dracula
139+
DARK_HIGH_CONTRAST -> R.style.Theme_SagerNet_DarkHighContrast
140+
DRACULA_M3 -> R.style.Theme_SagerNet_DraculaM3
141+
NORD -> R.style.Theme_SagerNet_Nord
142+
MONOKAI -> R.style.Theme_SagerNet_Monokai
143+
AYU -> R.style.Theme_SagerNet_Ayu
144+
CATPPUCCIN -> R.style.Theme_SagerNet_Catppuccin
80145
DYNAMIC -> R.style.Theme_SagerNet
81146
else -> getTheme(defaultTheme())
82147
}
@@ -107,6 +172,12 @@ object Theme {
107172
BLACK -> R.style.Theme_SagerNet_Dialog_Black
108173
VERDANT_MINT -> R.style.Theme_SagerNet_Dialog_VerdantMint
109174
DRACULA -> R.style.Theme_SagerNet_Dialog_Dracula
175+
DARK_HIGH_CONTRAST -> R.style.Theme_SagerNet_Dialog_DarkHighContrast
176+
DRACULA_M3 -> R.style.Theme_SagerNet_Dialog_DraculaM3
177+
NORD -> R.style.Theme_SagerNet_Dialog_Nord
178+
MONOKAI -> R.style.Theme_SagerNet_Dialog_Monokai
179+
AYU -> R.style.Theme_SagerNet_Dialog_Ayu
180+
CATPPUCCIN -> R.style.Theme_SagerNet_Dialog_Catppuccin
110181
DYNAMIC -> R.style.Theme_SagerNet_Dialog
111182
else -> getDialogTheme(defaultTheme())
112183
}

app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,11 +141,14 @@ class ServiceButton @JvmOverloads constructor(
141141
}
142142

143143
private fun applyStateTint(state: BaseService.State) {
144-
// Tint the connect FAB icon by state: connected=green, stopped=red. For
145-
// transient states (and on non-Dracula themes) fall back to colorOnPrimary,
146-
// which is the FAB's normal icon color, so those themes look unchanged.
144+
// Tint the connect FAB icon by state. Connected uses fabConnectedColor
145+
// (defaults to the green connected status color, but themes whose FAB
146+
// background is that same green — e.g. Dark High Contrast — override it
147+
// with a contrasting color so the icon stays visible). Stopped uses
148+
// fabStoppedColor. Transient states fall back to colorOnPrimary, the FAB's
149+
// normal icon color, so unaffected themes look unchanged.
147150
val attr = when (state) {
148-
BaseService.State.Connected -> R.attr.statusConnectedColor
151+
BaseService.State.Connected -> R.attr.fabConnectedColor
149152
BaseService.State.Stopped -> R.attr.fabStoppedColor
150153
else -> com.google.android.material.R.attr.colorOnPrimary
151154
}

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

Lines changed: 0 additions & 118 deletions
This file was deleted.

0 commit comments

Comments
 (0)