diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt index 240f4d9ee5..32273027e5 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt @@ -84,6 +84,43 @@ class AppSettings internal constructor( val colorScheme: ColorScheme get() = prefs.getEnumValue(KEY_COLOR_THEME, ColorScheme.default) + /** Parsed imported themes, cached in memory and invalidated on write. */ + val importedThemes: List + get() = cachedImportedThemes ?: ImportedThemeFile + .parseStored(prefs.getString(KEY_CUSTOM_THEMES, "{}") ?: "{}") + .also { cachedImportedThemes = it } + + var selectedCustomThemeId: String? + get() = prefs.getString(KEY_CUSTOM_THEME, null) + set(value) = prefs.edit { putString(KEY_CUSTOM_THEME, value) } + + val selectedCustomTheme: ImportedTheme? + get() = importedThemes.firstOrNull { it.id == selectedCustomThemeId } + + fun importCustomThemes(source: String): List { + val imported = ImportedThemeFile.parseImport(source) + val stored = prefs.getString(KEY_CUSTOM_THEMES, "{}") ?: "{}" + prefs.edit { putString(KEY_CUSTOM_THEMES, ImportedThemeFile.mergeStored(stored, imported)) } + cachedImportedThemes = null + return imported + } + + fun deleteCustomTheme(id: String) { + val themes = importedThemes.filterNot { it.id == id } + prefs.edit { + putString(KEY_CUSTOM_THEMES, ImportedThemeFile.mergeStored("{}", themes)) + if (selectedCustomThemeId == id) { + val nextThemeId = themes.firstOrNull()?.id + if (nextThemeId != null) { + putString(KEY_CUSTOM_THEME, nextThemeId) + } else { + remove(KEY_CUSTOM_THEME) + } + } + } + cachedImportedThemes = null + } + val isAmoledTheme: Boolean get() = prefs.getBoolean(KEY_THEME_AMOLED, false) @@ -762,6 +799,9 @@ class AppSettings internal constructor( companion object { + @Volatile + private var cachedImportedThemes: List? = null + const val TRACK_HISTORY = "history" const val TRACK_FAVOURITES = "favourites" @@ -773,6 +813,8 @@ class AppSettings internal constructor( const val KEY_THEME = "theme" const val KEY_COLOR_THEME = "color_theme" const val KEY_THEME_AMOLED = "amoled_theme" + const val KEY_CUSTOM_THEMES = "custom_themes" + const val KEY_CUSTOM_THEME = "custom_theme" const val KEY_OFFLINE_DISABLED = "no_offline" const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ColorScheme.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ColorScheme.kt index 0cf650cb84..ecd3f398ea 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ColorScheme.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ColorScheme.kt @@ -24,6 +24,7 @@ enum class ColorScheme( MAMIMI(R.style.ThemeOverlay_Futon_Mamimi, R.string.theme_name_mamimi), KANADE(R.style.ThemeOverlay_Futon_Kanade, R.string.theme_name_kanade), ITSUKA(R.style.ThemeOverlay_Futon_Itsuka, R.string.theme_name_itsuka), + CUSTOM(R.style.ThemeOverlay_Futon_Custom_Light, R.string.theme_name_custom), ; companion object { @@ -35,8 +36,11 @@ enum class ColorScheme( DEFAULT } - fun getAvailableList(): List { + fun getAvailableList(includeCustom: Boolean = false): List { val list = ColorScheme.entries.toMutableList() + if (!includeCustom || !DynamicColors.isDynamicColorAvailable()) { + list.remove(CUSTOM) + } if (!DynamicColors.isDynamicColorAvailable()) { list.remove(MONET) list.remove(EXPRESSIVE) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ImportedTheme.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ImportedTheme.kt new file mode 100644 index 0000000000..3be572ad88 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ImportedTheme.kt @@ -0,0 +1,108 @@ +package io.github.landwarderer.futon.core.prefs + +import org.json.JSONObject +import java.util.Locale + +/** A validated Idle Fantasy-compatible theme with its original definition retained for storage. */ +data class ImportedTheme( + val id: String, + val displayName: String, + val isDark: Boolean, + val primaryColor: Int, + val onPrimaryColor: Int?, + val json: String, +) + +object ImportedThemeFile { + const val MAX_FILE_SIZE = 256 * 1024 + private val ID_SANITIZATION_REGEX = Regex("[^\\p{L}\\p{N}]+") + private val HEX_COLOR_REGEX = Regex("^(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$") + + fun parseImport(source: String): List { + require(source.length <= MAX_FILE_SIZE) { "Theme file is too large" } + return parse(JSONObject(source), requireNonEmpty = true) + } + + fun parseStored(source: String): List = runCatching { + parse(JSONObject(source), requireNonEmpty = false) + }.getOrDefault(emptyList()) + + fun mergeStored(current: String, imported: List): String { + val root = runCatching { JSONObject(current) }.getOrElse { JSONObject() } + for (theme in imported) { + val obj = JSONObject(theme.json) + obj.put("display_name", theme.displayName) + root.put(theme.id, obj) + } + return root.toString() + } + + private fun parse(root: JSONObject, requireNonEmpty: Boolean): List { + val result = ArrayList(root.length()) + val keys = root.keys() + while (keys.hasNext()) { + val sourceName = keys.next() + val theme = root.optJSONObject(sourceName) ?: continue + val id = sourceName.trim().lowercase(Locale.ROOT) + .replace(ID_SANITIZATION_REGEX, "_") + .trim('_') + require(id.isNotBlank() && id.length <= 80) { "Theme name is missing or too long" } + + val base = theme.optString("base").lowercase(Locale.ROOT) + require(base == "light" || base == "dark") { "Theme '$sourceName' has an unsupported base" } + val colours = theme.optJSONObject("colours") ?: throw IllegalArgumentException("Theme '$sourceName' has no colours") + val scheme = theme.optJSONObject("colour_scheme") ?: throw IllegalArgumentException("Theme '$sourceName' has no colour_scheme") + require(colours.length() in 1..100) { "Theme '$sourceName' has no usable colours" } + require(scheme.length() in 1..100) { "Theme '$sourceName' has no colour roles" } + + val parsedColours = HashMap(colours.length()) + val colourNames = colours.keys() + while (colourNames.hasNext()) { + val name = colourNames.next() + parsedColours[name] = parseArgb(colours.getString(name)) + } + + val roleNames = HashMap(scheme.length()) + val roles = scheme.keys() + while (roles.hasNext()) { + val role = roles.next() + val colourName = scheme.getString(role) + require(parsedColours.containsKey(colourName)) { + "Theme '$sourceName' refers to missing colour '$colourName'" + } + roleNames[role] = colourName + } + val primaryName = roleNames["primary"] ?: roleNames["colorPrimary"] + ?: throw IllegalArgumentException("Theme '$sourceName' has no primary colour") + val onPrimary = (roleNames["on_primary"] ?: roleNames["onPrimary"])?.let(parsedColours::get) + val displayName = theme.optString("display_name").trim().ifEmpty { + sourceName.replace('_', ' ').replaceFirstChar { it.uppercase() } + } + theme.put("display_name", displayName) + + result += ImportedTheme( + id = id, + displayName = displayName, + isDark = base == "dark", + primaryColor = parsedColours.getValue(primaryName), + onPrimaryColor = onPrimary, + json = theme.toString(), + ) + } + require(!requireNonEmpty || result.isNotEmpty()) { "Theme file contains no themes" } + return result + } + + private fun parseArgb(value: String): Int { + val digits = value.trim().removePrefix("0x").removePrefix("0X").removePrefix("#") + require(HEX_COLOR_REGEX.matches(digits)) { + "Invalid hex colour value: $value" + } + val fullHex = when (digits.length) { + 3 -> "FF${digits[0]}${digits[0]}${digits[1]}${digits[1]}${digits[2]}${digits[2]}" + 6 -> "FF$digits" + else -> digits + } + return fullHex.toLong(16).toInt() + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/BaseActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/BaseActivity.kt index 72afedc34f..bfaa72c7ae 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/BaseActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/BaseActivity.kt @@ -19,6 +19,9 @@ import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.fragment.app.FragmentManager import androidx.viewbinding.ViewBinding +import androidx.appcompat.R as appcompatR +import com.google.android.material.color.DynamicColors +import com.google.android.material.color.DynamicColorsOptions import dagger.hilt.android.EntryPointAccessors import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -26,10 +29,10 @@ import io.github.landwarderer.futon.BuildConfig import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.exceptions.resolve.ExceptionResolver import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.prefs.ColorScheme import io.github.landwarderer.futon.core.ui.util.ActionModeDelegate import io.github.landwarderer.futon.core.util.ext.isWebViewUnavailable import io.github.landwarderer.futon.main.ui.protect.ScreenshotPolicyHelper -import androidx.appcompat.R as appcompatR abstract class BaseActivity : AppCompatActivity(), @@ -60,7 +63,27 @@ abstract class BaseActivity : override fun onCreate(savedInstanceState: Bundle?) { val settings = entryPoint.settings isAmoledTheme = settings.isAmoledTheme - setTheme(settings.colorScheme.styleResId) + val importedTheme = settings.selectedCustomTheme.takeIf { + settings.colorScheme == ColorScheme.CUSTOM && DynamicColors.isDynamicColorAvailable() + } + val colorScheme = when { + importedTheme == null && settings.colorScheme == ColorScheme.CUSTOM -> ColorScheme.default + else -> settings.colorScheme + } + setTheme(colorScheme.styleResId) + if (importedTheme != null) { + val isNightMode = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + setTheme( + if (isNightMode) R.style.ThemeOverlay_Futon_Custom_Dark + else R.style.ThemeOverlay_Futon_Custom_Light, + ) + DynamicColors.applyToActivityIfAvailable( + this, + DynamicColorsOptions.Builder() + .setContentBasedSource(importedTheme.primaryColor) + .build(), + ) + } if (isAmoledTheme) { setTheme(R.style.ThemeOverlay_Futon_Amoled) } @@ -70,6 +93,7 @@ abstract class BaseActivity : super.onCreate(savedInstanceState) } + override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) onBackPressedDispatcher.addCallback(actionModeDelegate) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/AppearanceSettingsFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/AppearanceSettingsFragment.kt index 04aa5fbbf7..d110d1feb2 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/AppearanceSettingsFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/AppearanceSettingsFragment.kt @@ -1,5 +1,6 @@ package io.github.landwarderer.futon.settings +import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri @@ -7,16 +8,29 @@ import android.os.Build import android.os.Bundle import android.provider.Settings import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.lifecycleScope import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference import androidx.preference.TwoStatePreference +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.parsers.util.names +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.parsers.util.toTitleCase import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.os.AppShortcutManager import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.prefs.ColorScheme import io.github.landwarderer.futon.core.prefs.HomeBackgroundFont +import io.github.landwarderer.futon.core.prefs.ImportedThemeFile import io.github.landwarderer.futon.core.prefs.ListMode import io.github.landwarderer.futon.core.prefs.ProgressIndicatorMode import io.github.landwarderer.futon.core.prefs.ScreenshotsPolicy @@ -27,17 +41,16 @@ import io.github.landwarderer.futon.core.ui.util.ActivityRecreationHandle import io.github.landwarderer.futon.core.util.LocaleComparator import io.github.landwarderer.futon.core.util.ext.getLocalesConfig import io.github.landwarderer.futon.core.util.ext.postDelayed +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.core.util.ext.setDefaultValueCompat import io.github.landwarderer.futon.core.util.ext.sortedWithSafe import io.github.landwarderer.futon.core.util.ext.toList -import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.parsers.util.toTitleCase import io.github.landwarderer.futon.settings.protect.ProtectSetupActivity import io.github.landwarderer.futon.settings.utils.ActivityListPreference import io.github.landwarderer.futon.settings.utils.MultiSummaryProvider import io.github.landwarderer.futon.settings.utils.PercentSummaryProvider import io.github.landwarderer.futon.settings.utils.SliderPreference +import io.github.landwarderer.futon.settings.utils.ThemeChooserPreference import javax.inject.Inject @AndroidEntryPoint @@ -45,6 +58,10 @@ class AppearanceSettingsFragment : BasePreferenceFragment(R.string.appearance), SharedPreferences.OnSharedPreferenceChangeListener { + private val customThemeFilePicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) importCustomTheme(uri) + } + @Inject lateinit var activityRecreationHandle: ActivityRecreationHandle @@ -97,6 +114,19 @@ class AppearanceSettingsFragment : pref.summaryProvider = MultiSummaryProvider(R.string.none) pref.values = settings.searchSuggestionTypes.mapToSet { it.name } } + findPreference(KEY_CUSTOM_THEMES_MANAGEMENT)?.setOnPreferenceClickListener { + showCustomThemes() + true + } + findPreference(KEY_CUSTOM_THEME_IMPORT)?.setOnPreferenceClickListener { + customThemeFilePicker.launch(arrayOf("*/*")) + true + } + findPreference(KEY_CUSTOM_THEME_DELETE)?.setOnPreferenceClickListener { + confirmDeleteCustomTheme() + true + } + updateCustomThemeSummaries() bindNavSummary() } @@ -122,6 +152,14 @@ class AppearanceSettingsFragment : postRestart() } + AppSettings.KEY_CUSTOM_THEME -> { + updateCustomThemeSummaries() + // Restart so the newly selected custom theme is applied to all activities + if (settings.colorScheme == ColorScheme.CUSTOM) postRestart() + } + + AppSettings.KEY_CUSTOM_THEMES -> updateCustomThemeSummaries() + AppSettings.KEY_APP_LOCALE -> { AppCompatDelegate.setApplicationLocales(settings.appLocales) } @@ -160,6 +198,108 @@ class AppearanceSettingsFragment : } } + private fun showCustomThemes() { + val themes = settings.importedThemes + if (themes.isEmpty()) { + customThemeFilePicker.launch(arrayOf("*/*")) + return + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.custom_themes) + .setSingleChoiceItems( + themes.map { it.displayName }.toTypedArray(), + themes.indexOfFirst { it.id == settings.selectedCustomThemeId }, + ) { dialog, index -> + settings.selectedCustomThemeId = themes[index].id + findPreference(AppSettings.KEY_COLOR_THEME)?.value = + ColorScheme.CUSTOM.name + updateCustomThemeSummaries() + dialog.dismiss() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun importCustomTheme(uri: Uri) { + val appContext = context?.applicationContext ?: return + viewLifecycleOwner.lifecycleScope.launch { + val result = runCatchingCancellable { + // Read from ContentResolver on IO thread to avoid blocking the main thread + // when importing from cloud storage providers (Drive, Nextcloud, etc.) + val json = withContext(Dispatchers.IO) { readThemeFile(appContext, uri) } + val imported = settings.importCustomThemes(json) + settings.selectedCustomThemeId = imported.last().id + findPreference(AppSettings.KEY_COLOR_THEME)?.value = + ColorScheme.CUSTOM.name + findPreference(AppSettings.KEY_COLOR_THEME)?.refresh() + updateCustomThemeSummaries() + context?.let { ctx -> + Toast.makeText(ctx, R.string.custom_theme_imported, Toast.LENGTH_SHORT).show() + } + } + result.onFailure { + it.printStackTraceDebug() + context?.let { ctx -> + Toast.makeText(ctx, R.string.custom_theme_import_failed, Toast.LENGTH_LONG).show() + } + } + } + } + + private fun readThemeFile(context: Context, uri: Uri): String { + val input = context.contentResolver.openInputStream(uri) + ?: throw IllegalArgumentException("Cannot open theme file") + return input.bufferedReader(Charsets.UTF_8).use { reader -> + val result = StringBuilder() + val buffer = CharArray(4096) + while (true) { + val length = reader.read(buffer) + if (length < 0) break + require(result.length + length <= ImportedThemeFile.MAX_FILE_SIZE) { + "Theme file is too large" + } + result.append(buffer, 0, length) + } + result.toString() + } + } + + private fun confirmDeleteCustomTheme() { + val selected = settings.selectedCustomTheme + if (selected == null) { + context?.let { ctx -> + Toast.makeText(ctx, R.string.custom_themes_none, Toast.LENGTH_SHORT).show() + } + return + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.custom_theme_delete) + .setMessage(getString(R.string.custom_theme_delete_confirm, selected.displayName)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + settings.deleteCustomTheme(selected.id) + if (settings.selectedCustomTheme == null && settings.colorScheme == ColorScheme.CUSTOM) { + findPreference(AppSettings.KEY_COLOR_THEME)?.value = + ColorScheme.default.name + } + findPreference(AppSettings.KEY_COLOR_THEME)?.refresh() + updateCustomThemeSummaries() + } + .show() + } + + private fun updateCustomThemeSummaries() { + val customTheme = settings.selectedCustomTheme + val emptySummary = getString(R.string.custom_themes_none) + findPreference(KEY_CUSTOM_THEMES_MANAGEMENT)?.summary = + customTheme?.displayName ?: emptySummary + findPreference(KEY_CUSTOM_THEME_DELETE)?.apply { + summary = customTheme?.displayName ?: emptySummary + isEnabled = customTheme != null + } + } + private fun initLocalePicker(preference: ListPreference) { val locales = preference.context.getLocalesConfig() .toList() @@ -187,4 +327,10 @@ class AppearanceSettingsFragment : getString(it.title) } } + + private companion object { + const val KEY_CUSTOM_THEMES_MANAGEMENT = "custom_themes_management" + const val KEY_CUSTOM_THEME_IMPORT = "custom_theme_import" + const val KEY_CUSTOM_THEME_DELETE = "custom_theme_delete" + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/ThemeChooserPreference.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/ThemeChooserPreference.kt index 0279fd40d4..83c0b7374b 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/ThemeChooserPreference.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/ThemeChooserPreference.kt @@ -19,6 +19,7 @@ import androidx.preference.Preference import androidx.preference.PreferenceViewHolder import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.prefs.ColorScheme +import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.databinding.ItemColorSchemeBinding import io.github.landwarderer.futon.databinding.PreferenceThemeBinding import java.lang.ref.WeakReference @@ -31,7 +32,16 @@ class ThemeChooserPreference @JvmOverloads constructor( defStyleRes: Int = R.style.Preference_ThemeChooser, ) : Preference(context, attrs, defStyleAttr, defStyleRes) { - private val entries = ColorScheme.getAvailableList() + private val settings = AppSettings(context.applicationContext) + private val entries: List + get() { + val available = ColorScheme.getAvailableList(includeCustom = settings.importedThemes.isNotEmpty()) + return if (currentValue == ColorScheme.CUSTOM && !available.contains(ColorScheme.CUSTOM)) { + available + ColorScheme.CUSTOM + } else { + available + } + } private var currentValue: ColorScheme = ColorScheme.default private val lastScrollPosition = intArrayOf(-1) private val itemClickListener = View.OnClickListener { @@ -53,18 +63,32 @@ class ThemeChooserPreference @JvmOverloads constructor( } binding.linear.removeAllViews() for (theme in entries) { - val context = ContextThemeWrapper(context, theme.styleResId) - val item = - ItemColorSchemeBinding.inflate(LayoutInflater.from(context), binding.linear, false) + val customTheme = settings.selectedCustomTheme.takeIf { theme == ColorScheme.CUSTOM } + val styleResId = when { + theme == ColorScheme.CUSTOM && customTheme?.isDark == true -> R.style.ThemeOverlay_Futon_Custom_Dark + else -> theme.styleResId + } + val themedContext = ContextThemeWrapper(context, styleResId) + val item = ItemColorSchemeBinding.inflate(LayoutInflater.from(themedContext), binding.linear, false) if (binding.linear.isEmpty()) { item.root.updatePaddingRelative(start = 0) } val isSelected = theme == currentValue item.card.isChecked = isSelected - item.card.strokeWidth = if (isSelected) context.resources.getDimensionPixelSize( + item.card.strokeWidth = if (isSelected) themedContext.resources.getDimensionPixelSize( materialR.dimen.m3_comp_outlined_card_outline_width, ) else 0 - item.textViewTitle.setText(theme.titleResId) + item.textViewTitle.text = customTheme?.displayName ?: themedContext.getString(theme.titleResId) + if (customTheme != null) { + item.shapePrimary.backgroundTintList = + android.content.res.ColorStateList.valueOf(customTheme.primaryColor) + customTheme.onPrimaryColor?.let { onPrimary -> + item.shape1.backgroundTintList = + android.content.res.ColorStateList.valueOf(onPrimary) + item.shape2.backgroundTintList = + android.content.res.ColorStateList.valueOf(onPrimary) + } + } item.root.tag = theme item.card.tag = theme item.imageViewCheck.isVisible = theme == currentValue @@ -123,6 +147,9 @@ class ThemeChooserPreference @JvmOverloads constructor( lastScrollPosition[0] = state.scrollPosition } + /** Externally refresh this preference's view when custom-theme data has changed. */ + fun refresh() = notifyChanged() + private fun setValueInternal(enumName: String, notifyChanged: Boolean) { val newValue = ColorScheme.safeValueOf(enumName) ?: return if (newValue != currentValue) { diff --git a/app/src/main/res/layout/item_color_scheme.xml b/app/src/main/res/layout/item_color_scheme.xml index 0244b5c7de..d5df084ff8 100644 --- a/app/src/main/res/layout/item_color_scheme.xml +++ b/app/src/main/res/layout/item_color_scheme.xml @@ -61,6 +61,7 @@ app:layout_constraintWidth_percent="0.7" /> Dynamic Expressive (Test) Color scheme + Custom + Custom themes + No custom themes imported + Import theme file + Import an Idle Fantasy compatible Material 3 theme JSON + Theme imported and applied + Could not import this theme file + Delete selected theme + Delete ā€œ%1$sā€? Show in grid view Miku Asuka diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 9cb548a6e6..7fcd3af316 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -84,6 +84,8 @@