Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/src/main/java/io/nekohasekai/sagernet/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,22 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
DataStore.initGlobal()
addPreferencesFromResource(R.xml.global_preferences)

val appTheme = findPreference<ColorPickerPreference>(Key.APP_THEME)!!
val appTheme = findPreference<ThemePickerPreference>(Key.APP_THEME)!!
val nightTheme = findPreference<SimpleMenuPreference>(Key.NIGHT_THEME)!!
appTheme.setOnPreferenceChangeListener { _, newTheme ->
if (DataStore.serviceState.started) {
SagerNet.reloadService()
}
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) {
Comment thread
hawkff marked this conversation as resolved.
DataStore.nightThemeBeforeDracula = DataStore.nightTheme
Theme.currentNightMode = 1
// nightTheme.value persists to configurationStore (same key as
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.nekohasekai.sagernet.ui
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
Expand Down Expand Up @@ -59,6 +60,12 @@ abstract class ThemedActivity : AppCompatActivity {
findViewById<AppBarLayout>(R.id.appbar)?.apply {
updatePadding(top = bars.top)
}
// 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<View>(R.id.stats)?.apply {
updatePadding(bottom = bars.bottom)
}
insets
}
}
Expand Down
71 changes: 71 additions & 0 deletions app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,68 @@ object Theme {
const val VERDANT_MINT = 22
const val DRACULA = 23
const val DYNAMIC = 24
const val DARK_HIGH_CONTRAST = 25
const val DRACULA_M3 = 26
const val NORD = 27
const val MONOKAI = 28
const val AYU = 29
const val CATPPUCCIN = 30

/**
* 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; the modern M3 themes are dark-only too.
*/
val DARK_ONLY_THEMES = setOf(
DRACULA, DARK_HIGH_CONTRAST, DRACULA_M3, NORD, MONOKAI, AYU, CATPPUCCIN,
)

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
*/
/**
* 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 "Legacy colors…" grid.
* Order here is the display order in the dialog.
*/
val MODERN_THEMES: List<ThemeInfo> = listOf(
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),
ThemeInfo(AYU, R.string.theme_ayu, R.color.ayu_primary),
ThemeInfo(CATPPUCCIN, R.string.theme_catppuccin, R.color.catppuccin_primary),
ThemeInfo(DYNAMIC, R.string.theme_dynamic, R.color.color_dynamic_swatch),
)

fun apply(context: Context) {
context.setTheme(getTheme())
}
Expand Down Expand Up @@ -77,6 +136,12 @@ 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
DRACULA_M3 -> R.style.Theme_SagerNet_DraculaM3
NORD -> R.style.Theme_SagerNet_Nord
MONOKAI -> R.style.Theme_SagerNet_Monokai
AYU -> R.style.Theme_SagerNet_Ayu
CATPPUCCIN -> R.style.Theme_SagerNet_Catppuccin
DYNAMIC -> R.style.Theme_SagerNet
else -> getTheme(defaultTheme())
}
Expand Down Expand Up @@ -107,6 +172,12 @@ 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
DRACULA_M3 -> R.style.Theme_SagerNet_Dialog_DraculaM3
NORD -> R.style.Theme_SagerNet_Dialog_Nord
MONOKAI -> R.style.Theme_SagerNet_Dialog_Monokai
AYU -> R.style.Theme_SagerNet_Dialog_Ayu
CATPPUCCIN -> R.style.Theme_SagerNet_Dialog_Catppuccin
DYNAMIC -> R.style.Theme_SagerNet_Dialog
else -> getDialogTheme(defaultTheme())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,14 @@ class ServiceButton @JvmOverloads constructor(
}

private fun applyStateTint(state: BaseService.State) {
// Tint the connect FAB icon by state: connected=green, stopped=red. For
// transient states (and on non-Dracula themes) fall back to colorOnPrimary,
// which is the FAB's normal icon color, so those themes look unchanged.
// Tint the connect FAB icon by state. Connected uses fabConnectedColor
// (defaults to the green connected status color, but themes whose FAB
// background is that same green — e.g. Dark High Contrast — override it
// with a contrasting color so the icon stays visible). Stopped uses
// fabStoppedColor. Transient states fall back to colorOnPrimary, the FAB's
// normal icon color, so unaffected themes look unchanged.
val attr = when (state) {
BaseService.State.Connected -> R.attr.statusConnectedColor
BaseService.State.Connected -> R.attr.fabConnectedColor
BaseService.State.Stopped -> R.attr.fabStoppedColor
else -> com.google.android.material.R.attr.colorOnPrimary
}
Expand Down
118 changes: 0 additions & 118 deletions app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt

This file was deleted.

Loading
Loading