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/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/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt index e245e658e..2251cb551 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) { @@ -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/ui/ThemedActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt index 1825b7385..773a89c2e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt @@ -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 @@ -59,6 +60,12 @@ abstract class ThemedActivity : AppCompatActivity { findViewById(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(R.id.stats)?.apply { + updatePadding(bottom = bars.bottom) + } insets } } 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..6e00ca68a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt @@ -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 = 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()) } @@ -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()) } @@ -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()) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt index d5b69e7e2..37424aacd 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt @@ -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 } 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..50b95ac68 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/ui/ThemePickerPreference.kt @@ -0,0 +1,263 @@ +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 +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 +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 +) { + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val widgetFrame = holder.findViewById(android.R.id.widget_frame) as? LinearLayout + ?: return + + // 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) + ) + 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 + } + + /** + * 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() + + 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) { + 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() + } + ) + } + + // 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) + // 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() + } + + private fun buildRow( + name: String, + swatchColor: Int?, + ringColor: Int? = null, + 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) + // Announce the theme name to screen readers when the row is focused. + 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)) + 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 { + contentDescription = + context.getString(R.string.theme_classic_color_swatch, themeId) + 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) { + // 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/layout/layout_app_list.xml b/app/src/main/res/layout/layout_app_list.xml index 976e405c9..73b4a1633 100644 --- a/app/src/main/res/layout/layout_app_list.xml +++ b/app/src/main/res/layout/layout_app_list.xml @@ -11,7 +11,7 @@ android:id="@+id/appbar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="?attr/colorPrimary"> + android:background="?attr/appBarBackgroundColor"> @@ -12,7 +12,7 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?attr/colorPrimary" + android:background="?attr/appBarBackgroundColor" android:theme="?actionBarTheme" android:touchscreenBlocksFocus="false" app:popupTheme="@style/ThemeOverlay.SagerNet.Toolbar.Popup" diff --git a/app/src/main/res/layout/layout_apps.xml b/app/src/main/res/layout/layout_apps.xml index 41c9f2f83..decab9af6 100644 --- a/app/src/main/res/layout/layout_apps.xml +++ b/app/src/main/res/layout/layout_apps.xml @@ -11,7 +11,7 @@ android:id="@+id/appbar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="?attr/colorPrimary"> + android:background="?attr/appBarBackgroundColor"> - - - - - - - + android:orientation="vertical" + android:paddingTop="24dp" + android:paddingBottom="8dp"> diff --git a/app/src/main/res/layout/layout_route.xml b/app/src/main/res/layout/layout_route.xml index 971231f36..a2bce33ba 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="168dp" android:scrollbarSize="0dp" app:fastScrollAutoHide="true" app:fastScrollThumbColor="?colorPrimary" diff --git a/app/src/main/res/layout/layout_route_item.xml b/app/src/main/res/layout/layout_route_item.xml index 4758f6e83..fbfba4904 100644 --- a/app/src/main/res/layout/layout_route_item.xml +++ b/app/src/main/res/layout/layout_route_item.xml @@ -126,7 +126,7 @@ - - diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index c1abd02a6..91d5f83b0 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -14,6 +14,7 @@ --> + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index f01246e2b..a4ef09f4a 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -30,6 +30,12 @@ (red was invisible on the pink/purple FAB). Separate from the red statusStoppedColor used for the status text. --> + + @@ -43,4 +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 926a2500f..22b684491 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -374,4 +374,140 @@ #343746 #F8F8F2 + + #3FB950 + #2EA043 + #0A0A0A + #71B7FF + #4493F8 + #000000 + #0D0D0D + #161616 + #1C1C1C + #F0F6FC + #C9D1D9 + #3D444D + #21262D + #3FB950 + #F85149 + #D29922 + #1C1C1C + #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 + #74C7EC + #1E1E2E + #89B4FA + #89B4FA + #1E1E2E + #181825 + #313244 + #45475A + #CDD6F4 + #BAC2DE + #6C7086 + #313244 + #A6E3A1 + #F38BA8 + #F9E2AF + #45475A + #6C7086 + #181825 + \ 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 bedec5863..8bdae08fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,15 @@ Group About Theme + Dynamic (Material You) + Dark High Contrast + Dracula + Nord + Monokai + Ayu + Catppuccin + Legacy colors… + Color theme %1$d Document Ungrouped Toggle diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 793bc7eaa..f4acaa198 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -17,6 +17,9 @@ @color/material_red_500 ?attr/colorOnPrimary ?attr/colorOnPrimary + + ?attr/statusConnectedColor ?attr/colorOnPrimary ?attr/colorOnPrimary @color/color_route_proxy @@ -30,6 +33,7 @@ ?colorAccent ?colorPrimaryDark @color/material_pink_500 + @color/material_pink_500 @color/material_pink_700 @color/material_pink_accent_200 @color/white @@ -113,6 +117,9 @@ @color/material_red_500 ?attr/colorOnPrimary ?attr/colorOnPrimary + + ?attr/statusConnectedColor ?attr/colorOnPrimary ?attr/colorOnPrimary @color/color_route_proxy @@ -122,6 +129,7 @@ ?colorAccent ?colorPrimaryDark @color/material_pink_500 + @color/material_pink_500 @color/material_pink_700 @color/material_pink_accent_200 @color/white @@ -241,6 +249,28 @@ false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +