Skip to content

Commit d7feef8

Browse files
committed
feat: monet color
1 parent 4c0384f commit d7feef8

12 files changed

Lines changed: 234 additions & 2 deletions

File tree

THEME_SCHEMA.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# NekoBoxForAndroid (nb4a) 主题色系统设计与实现指南
2+
3+
本项目实现了一套优雅、灵活且支持动态切换的主题色系统。该系统不仅支持 22 种不同的主题配色,还完美融合了 Android 系统的深色模式(夜间模式)以及全面屏沉浸式体验(Edge-to-Edge)。
4+
5+
---
6+
7+
## 1. 整体架构与核心流程
8+
9+
主题色系统的整体架构遵循 **“存储偏好 -> 控件交互 -> 动态监听 -> 运行时应用 -> 资源渲染”** 的闭环流程:
10+
11+
```
12+
[ 资源定义 (themes.xml / attrs.xml / colors.xml) ]
13+
14+
15+
[ 偏好存储 (DataStore) ] <─── [ 控件交互 (ColorPickerPreference) ]
16+
17+
18+
[ 动态监听 (SettingsPreferenceFragment) ] ───► [ 重建 Activity (recreate) ]
19+
20+
21+
[ 运行时应用 (Theme.kt / ThemedActivity) ]
22+
```
23+
24+
---
25+
26+
## 2. 核心组件详解
27+
28+
### 2.1 偏好设置存储 (DataStore)
29+
主题和夜间模式的配置持久化存储在 SharedPreferences 中,并通过 [`DataStore.kt`](app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt) 进行封装:
30+
- **`DataStore.appTheme`**:存储当前选择的主题 ID(整数,范围 1 ~ 22)。
31+
- 对应 Key:`Key.APP_THEME`(即 `"appTheme"`),定义在 [`Constants.kt`](app/src/main/java/io/nekohasekai/sagernet/Constants.kt:15)
32+
- **`DataStore.useSystemTheme`**:存储是否使用系统主题色(布尔值,默认 `false`)。
33+
- 对应 Key:`Key.USE_SYSTEM_THEME`(即 `"useSystemTheme"`),定义在 [`Constants.kt`](app/src/main/java/io/nekohasekai/sagernet/Constants.kt:16)
34+
- **`DataStore.nightTheme`**:存储当前选择的夜间模式设置(0: 跟随系统, 1: 开启, 2: 关闭)。
35+
- 对应 Key:`Key.NIGHT_THEME`(即 `"nightTheme"`),定义在 [`Constants.kt`](app/src/main/java/io/nekohasekai/sagernet/Constants.kt:16)
36+
37+
### 2.2 主题色选择器 (ColorPickerPreference)
38+
[`ColorPickerPreference.kt`](app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt) 是一个自定义的 `Preference` 控件,配置在 [`global_preferences.xml`](app/src/main/res/xml/global_preferences.xml:11) 中:
39+
- **交互逻辑**
40+
-[`onBindViewHolder`](app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt:38) 中,动态在 Preference 右侧的 `widget_frame` 中添加一个圆形颜色预览(使用 `R.drawable.ic_baseline_fiber_manual_record_24` 并通过 `DrawableCompat.setTint` 染色)。
41+
- 点击该 Preference 时,触发 [`onClick`](app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt:80),弹出一个 `MaterialAlertDialog`
42+
- 对话框内包含一个 4 列的 `GridLayout`,展示从 `R.array.material_colors`(定义在 [`colors.xml`](app/src/main/res/values/colors.xml:4))中读取的 22 种主题色。
43+
- 用户点击某种颜色时,将对应的索引(从 1 开始的 `themeId`)通过 `persistInt(themeId)` 持久化,并调用 `callChangeListener(themeId)` 触发监听器。
44+
45+
### 2.3 主题应用逻辑 (Theme.kt)
46+
[`Theme.kt`](app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt) 是主题系统的核心调度单例:
47+
- **主题映射**
48+
- 定义了 22 种主题色常量(如 `RED = 1`, `PINK_SSR = 2`, `BLACK = 21`, `VERDANT_MINT = 22`),以及系统 Monet 动态主题常量 `MONET = 0`
49+
- [`getTheme(theme: Int)`](app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt:53) 将主题 ID 映射为对应的 Android Style 资源 ID(如 `R.style.Theme_SagerNet_Red`,或 `R.style.Theme_SagerNet_Monet`)。
50+
- [`getDialogTheme(theme: Int)`](app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt:81) 将主题 ID 映射为对应的 Dialog Style 资源 ID(如 `R.style.Theme_SagerNet_Dialog_Red`,或 `R.style.Theme_SagerNet_Dialog_Monet`)。
51+
- **夜间模式调度**
52+
- [`getNightMode()`](app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt:110) 将用户设置的 `nightTheme` 映射为 `AppCompatDelegate` 的夜间模式常量(如 `MODE_NIGHT_FOLLOW_SYSTEM`)。
53+
- [`applyNightTheme()`](app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt:134) 调用 `AppCompatDelegate.setDefaultNightMode` 应用夜间模式。
54+
- [`usingNightMode()`](app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt:126) 用于判断当前实际是否处于夜间模式(考虑了系统深色模式状态)。
55+
56+
### 2.4 Activity 基类应用 (ThemedActivity)
57+
[`ThemedActivity.kt`](app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt) 是所有需要应用主题的 Activity 的基类:
58+
- **生命周期注入**
59+
-[`onCreate`](app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt:28) 中,在调用 `super.onCreate` 之前,先调用 `Theme.apply(this)`(或 `Theme.applyDialog(this)`)和 `Theme.applyNightTheme()`,确保 Activity 在加载布局前已应用正确的主题样式。
60+
- **系统配置监听**
61+
- 重写 [`onConfigurationChanged`](app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt:66),当系统深色模式发生切换(`uiMode` 改变)时,调用 `ActivityCompat.recreate(this)` 重建 Activity 以刷新界面。
62+
63+
### 2.5 动态主题切换 (SettingsPreferenceFragment)
64+
在设置界面 [`SettingsPreferenceFragment.kt`](app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt) 中,实现了主题的即时动态刷新:
65+
- **系统主题色开关监听**
66+
- 仅在 Android 12+ (API 31+) 系统上显示。
67+
- 开启时,禁用 `appTheme`(ColorPickerPreference)选择器,并强行载入绑定了 Monet API 的主题 `Theme.SagerNet.Monet`
68+
- 关闭时,启用 `appTheme` 选择器,并恢复用户之前选择的主题。
69+
- **主题色改变监听**
70+
```kotlin
71+
val appTheme = findPreference<ColorPickerPreference>(Key.APP_THEME)!!
72+
appTheme.setOnPreferenceChangeListener { _, newTheme ->
73+
if (DataStore.serviceState.started) {
74+
SagerNet.reloadService() // 重新加载服务以刷新通知栏颜色
75+
}
76+
val theme = Theme.getTheme(newTheme as Int)
77+
app.setTheme(theme) // 应用到 Application
78+
requireActivity().apply {
79+
setTheme(theme) // 应用到当前 Activity
80+
ActivityCompat.recreate(this) // 重建当前 Activity
81+
}
82+
true
83+
}
84+
```
85+
- **夜间模式改变监听**
86+
```kotlin
87+
val nightTheme = findPreference<SimpleMenuPreference>(Key.NIGHT_THEME)!!
88+
nightTheme.setOnPreferenceChangeListener { _, newTheme ->
89+
Theme.currentNightMode = (newTheme as String).toInt()
90+
Theme.applyNightTheme() // 触发 AppCompatDelegate 刷新
91+
true
92+
}
93+
```
94+
95+
---
96+
97+
## 3. 资源文件定义
98+
99+
### 3.1 自定义主题属性 (attrs.xml)
100+
为了让不同的主题变体能够灵活控制特定 UI 元素的颜色,项目在 [`attrs.xml`](app/src/main/res/values/attrs.xml) 中定义了一系列自定义属性:
101+
- `colorPrimary` / `colorPrimaryDark` / `colorAccent`:标准的 Material Design 核心颜色。
102+
- `colorMaterial100` / `colorMaterial300`:用于特定背景或辅助色。
103+
- `cardElevatedSurfaceColor`:立体卡片(如路由、分组卡片)的“色调提升表面色”,各主题变体朝自身 primary 颜色进行染色。
104+
- `accentOrTextSecondary` / `accentOrTextPrimary`:在普通模式下使用 Accent 色,在特定模式下退化为文本颜色的自适应属性。
105+
- `primaryOrTextSecondary` / `primaryOrTextPrimary`:自适应 Primary 或文本颜色的属性。
106+
- `fabColorBackground`:悬浮操作按钮(FAB)的背景色。
107+
- `selectedColorPrimary`:选中状态下的主色调。
108+
109+
### 3.2 基础主题与变体 (themes.xml)
110+
[`themes.xml`](app/src/main/res/values/themes.xml) 中定义了完整的主题树:
111+
- **基础应用主题**`Theme.SagerNet` 继承自 `Theme.MaterialComponents.DayNight.NoActionBar`,定义了通用的 Material 组件样式(如 Dialog、CardView、TextInputLayout、Button 等的 Style 映射)。
112+
- **基础对话框主题**`Theme.SagerNet.Dialog` 继承自 `Theme.MaterialComponents.DayNight.Dialog.Alert`
113+
- **22 种主题变体**
114+
- 每一个主题变体(如 `Theme.SagerNet.Red``Theme.SagerNet.Amber` 等)都继承自 `Theme.SagerNet`,并覆盖了核心颜色属性。
115+
- 每一个对话框主题变体(如 `Theme.SagerNet.Dialog.Red` 等)都继承自 `Theme.SagerNet.Dialog`,并覆盖了核心颜色属性。
116+
- **黑色主题 (`Theme.SagerNet.Black`)** 进行了特殊定制,将 `accentOrTextSecondary` 等自适应属性退化为系统默认的文本颜色(`?android:textColorSecondary`),以保证高对比度和极简视觉。
117+
118+
### 3.3 沉浸式适配 (values-v26/themes.xml)
119+
[`values-v26/themes.xml`](app/src/main/res/values-v26/themes.xml) 中,针对 Android 8.0+ (API 26+) 进行了沉浸式适配:
120+
-`android:statusBarColor``android:navigationBarColor` 设置为 `@android:color/transparent`
121+
-[`ThemedActivity.kt`](app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt:40) 中,通过 `WindowCompat.setDecorFitsSystemWindows(window, false)` 开启全面屏沉浸式。
122+
- 使用 `WindowCompat.getInsetsController` 动态控制状态栏和导航栏图标的亮色/暗色外观:
123+
- 导航栏图标:`insetController.isAppearanceLightNavigationBars = !Theme.usingNightMode()`
124+
- 状态栏图标:对于黑色主题(`Theme.BLACK`),在非夜间模式下将状态栏图标设为亮色(深色图标),其他主题默认保持暗色(白色图标)。
125+
126+
---
127+
128+
## 4. 开发者指南:如何新增一个主题色
129+
130+
若要在未来版本中新增一种主题色(例如命名为 `Emerald` 祖母绿):
131+
132+
1. **[`colors.xml`](app/src/main/res/values/colors.xml) 中定义颜色**
133+
- 添加 `material_emerald_500``material_emerald_700``material_emerald_accent_200` 等颜色值。
134+
-`material_emerald_500` 添加到 `material_colors` 整数数组中。
135+
136+
2. **[`Theme.kt`](app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt) 中注册常量**
137+
-`Theme` 单例中添加 `const val EMERALD = 23`
138+
-`getTheme(theme: Int)` 中添加 `EMERALD -> R.style.Theme_SagerNet_Emerald`
139+
-`getDialogTheme(theme: Int)` 中添加 `EMERALD -> R.style.Theme_SagerNet_Dialog_Emerald`
140+
141+
3. **[`themes.xml`](app/src/main/res/values/themes.xml) 中定义 Style**
142+
- 创建 `Theme.SagerNet.Emerald` 继承自 `Theme.SagerNet`,并覆盖相关颜色属性。
143+
- 创建 `Theme.SagerNet.Dialog.Emerald` 继承自 `Theme.SagerNet.Dialog`,并覆盖相关颜色属性。

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ object Key {
1313

1414
const val APP_EXPERT = "isExpert"
1515
const val APP_THEME = "appTheme"
16+
const val USE_SYSTEM_THEME = "useSystemTheme"
1617
const val NIGHT_THEME = "nightTheme"
1718
const val APP_LANGUAGE = "appLanguage"
1819
const val SERVICE_MODE = "serviceMode"

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ object DataStore : OnPreferenceDataStoreChangeListener {
102102

103103
var isExpert by configurationStore.boolean(Key.APP_EXPERT)
104104
var appTheme by configurationStore.int(Key.APP_THEME)
105+
var useSystemTheme by configurationStore.boolean(Key.USE_SYSTEM_THEME)
105106
var nightTheme by configurationStore.stringToInt(Key.NIGHT_THEME)
106107
var appLanguage by configurationStore.string(Key.APP_LANGUAGE) { "" }
107108
var serviceMode by configurationStore.string(Key.SERVICE_MODE) { Key.MODE_VPN }

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,27 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
4747
addPreferencesFromResource(R.xml.global_preferences)
4848

4949
val appTheme = findPreference<ColorPickerPreference>(Key.APP_THEME)!!
50+
val useSystemTheme = findPreference<SwitchPreference>(Key.USE_SYSTEM_THEME)!!
51+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
52+
useSystemTheme.isVisible = false
53+
} else {
54+
useSystemTheme.setOnPreferenceChangeListener { _, newValue ->
55+
val enabled = newValue as Boolean
56+
appTheme.isEnabled = !enabled
57+
if (DataStore.serviceState.started) {
58+
SagerNet.reloadService()
59+
}
60+
val theme = if (enabled) Theme.getTheme(Theme.MONET) else Theme.getTheme(DataStore.appTheme)
61+
app.setTheme(theme)
62+
requireActivity().apply {
63+
setTheme(theme)
64+
ActivityCompat.recreate(this)
65+
}
66+
true
67+
}
68+
appTheme.isEnabled = !DataStore.useSystemTheme
69+
}
70+
5071
appTheme.setOnPreferenceChangeListener { _, newTheme ->
5172
if (DataStore.serviceState.started) {
5273
SagerNet.reloadService()

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package io.nekohasekai.sagernet.utils
22

33
import android.content.Context
44
import android.content.res.Configuration
5+
import android.os.Build
56
import androidx.appcompat.app.AppCompatDelegate
67
import io.nekohasekai.sagernet.R
78
import io.nekohasekai.sagernet.database.DataStore
89
import io.nekohasekai.sagernet.ktx.app
910

1011
object Theme {
1112

13+
const val MONET = 0
1214
const val RED = 1
1315
const val PINK_SSR = 2
1416
const val PINK = 3
@@ -43,15 +45,24 @@ object Theme {
4345
}
4446

4547
fun getTheme(): Int {
46-
return getTheme(DataStore.appTheme)
48+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && DataStore.useSystemTheme) {
49+
getTheme(MONET)
50+
} else {
51+
getTheme(DataStore.appTheme)
52+
}
4753
}
4854

4955
fun getDialogTheme(): Int {
50-
return getDialogTheme(DataStore.appTheme)
56+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && DataStore.useSystemTheme) {
57+
getDialogTheme(MONET)
58+
} else {
59+
getDialogTheme(DataStore.appTheme)
60+
}
5161
}
5262

5363
fun getTheme(theme: Int): Int {
5464
return when (theme) {
65+
MONET -> R.style.Theme_SagerNet_Monet
5566
RED -> R.style.Theme_SagerNet_Red
5667
PINK -> R.style.Theme_SagerNet
5768
PINK_SSR -> R.style.Theme_SagerNet_Pink_SSR
@@ -80,6 +91,7 @@ object Theme {
8091

8192
fun getDialogTheme(theme: Int): Int {
8293
return when (theme) {
94+
MONET -> R.style.Theme_SagerNet_Dialog_Monet
8395
RED -> R.style.Theme_SagerNet_Dialog_Red
8496
PINK -> R.style.Theme_SagerNet_Dialog
8597
PINK_SSR -> R.style.Theme_SagerNet_Dialog_Pink_SSR
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<resources>
3+
<color name="monet_primary">@android:color/system_accent1_200</color>
4+
<color name="monet_primary_variant">@android:color/system_accent1_300</color>
5+
<color name="monet_secondary">@android:color/system_accent2_200</color>
6+
<color name="monet_material_100">@android:color/system_accent1_100</color>
7+
<color name="monet_material_300">@android:color/system_accent1_300</color>
8+
<color name="monet_card_elevated">@android:color/system_neutral1_900</color>
9+
</resources>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<resources>
3+
<color name="monet_primary">@android:color/system_accent1_600</color>
4+
<color name="monet_primary_variant">@android:color/system_accent1_700</color>
5+
<color name="monet_secondary">@android:color/system_accent2_500</color>
6+
<color name="monet_material_100">@android:color/system_accent1_100</color>
7+
<color name="monet_material_300">@android:color/system_accent1_300</color>
8+
<color name="monet_card_elevated">@android:color/system_neutral1_10</color>
9+
</resources>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<resources>
3+
4+
<style name="Theme.SagerNet.Monet" parent="Theme.SagerNet">
5+
<item name="colorPrimary">@color/monet_primary</item>
6+
<item name="colorPrimaryDark">@color/monet_primary_variant</item>
7+
<item name="colorAccent">@color/monet_secondary</item>
8+
<item name="colorMaterial100">@color/monet_material_100</item>
9+
<item name="colorMaterial300">@color/monet_material_300</item>
10+
<item name="cardElevatedSurfaceColor">@color/monet_card_elevated</item>
11+
</style>
12+
13+
<style name="Theme.SagerNet.Dialog.Monet" parent="Theme.SagerNet.Dialog">
14+
<item name="colorPrimary">@color/monet_primary</item>
15+
<item name="colorPrimaryDark">@color/monet_primary_variant</item>
16+
<item name="colorAccent">@color/monet_secondary</item>
17+
<item name="colorMaterial100">@color/monet_material_100</item>
18+
<item name="colorMaterial300">@color/monet_material_300</item>
19+
<item name="cardElevatedSurfaceColor">@color/monet_card_elevated</item>
20+
</style>
21+
22+
</resources>

app/src/main/res/values-zh-rCN/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,8 @@
238238
<string name="file_manager_missing">您的设备缺少 Android 标准文件选择器, 请安装一个, 如 Material Files.</string>
239239
<string name="need_reload">重载代理服务以应用修改</string>
240240
<string name="theme">主题颜色</string>
241+
<string name="use_system_theme">使用系统主题色</string>
242+
<string name="use_system_theme_summary">使用系统 Monet 动态取色 (Android 12+)</string>
241243
<string name="donate">捐款</string>
242244
<string name="donate_info">猫猫很可爱 请给猫猫钱</string>
243245
<string name="extra_headers">附加标头</string>

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="use_system_theme">Use system theme</string>
18+
<string name="use_system_theme_summary">Use system Monet colors (Android 12+)</string>
1719
<string name="document">Document</string>
1820
<string name="group_default">Ungrouped</string>
1921
<string name="quick_toggle">Toggle</string>

0 commit comments

Comments
 (0)