mirror of https://github.com/deniscerri/ytdlnis
Add thems support (#19)
* Default (#15) * Create Test Debug Release.yml * Add files via upload * Refactor GitHub Actions workflow for debug release (#5) * Refactor GitHub Actions workflow for debug release * Update Test Debug Release.yml * Delete .github/workflows/Test Debug Release.yml * Add files via upload * added theme suppoet (#11) * added theme suppoet * Fix theme preset mode handling and picker filtering (#12) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Add files via upload * Update app/src/main/res/values/theme_presets.xml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Persist theme preset settings before refreshing the UI (#13) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Atomically persist light and dark theme presets (#14) * Add files via upload * Add files via upload * Update AccentAdapter.kt * Delete test-debug-release.yml --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Use black text on Rena and Pixel primary colors (#16) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Add files via upload (#18) * Add files via upload * Add files via upload * adding finishing touch * rebase * fix1 * fix2 * Add files via upload * Add files via upload * Update .github/workflows/test-debug-release.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Adjust Ukrrooter theme surface color (#20) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Add curated theme presets with automatic light and dark switching What Changed Users can enable 12 full-color theme presets instead of separately choosing an accent, light/dark mode, and contrast setting. Users can apply one fixed preset or choose separate light and dark presets that follow the system appearance. Theme and accent choices now open visual card pickers, with changes applied immediately and saved for future launches. Existing accent, light/dark, and high-contrast controls are hidden while presets are enabled. Preset selection now keeps light and dark choices together, and dark-mode handling no longer overrides automatic preset selection. Added coverage for light-mode, dark-mode, automatic switching, and fixed dark preset behavior. Added a workflow for producing architecture-specific debug builds for internal testing.pull/1360/head
parent
4918fb28d0
commit
66fab61bac
@ -0,0 +1,241 @@
|
||||
name: "Test Debug Release"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
architecture:
|
||||
description: 'APK architecture to upload for manual test builds'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- arm64-v8a
|
||||
- armeabi-v7a
|
||||
- x86
|
||||
- x86_64
|
||||
- universal
|
||||
default: universal
|
||||
push:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-debug:
|
||||
name: Build test debug APK
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
cache: 'gradle'
|
||||
verify-signature: true
|
||||
|
||||
- name: Read latest dated version from CHANGELOG.md
|
||||
id: changelog
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! -f CHANGELOG.md ]]; then
|
||||
echo "::error::CHANGELOG.md not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Expected format (this repo's changelog uses a blockquoted heading):
|
||||
# > # 1.8.9.1 (2026-06)
|
||||
CHANGELOG_LINE="$(
|
||||
grep -m1 -E '^>[[:space:]]+#[[:space:]]+[^[:space:]]+[[:space:]]+\([0-9]{4}-[0-9]{2}\)[[:space:]]*$' CHANGELOG.md
|
||||
)" || {
|
||||
echo "::error::No dated version heading was found in CHANGELOG.md"
|
||||
echo "::error::Expected: > # 1.8.9.1 (YYYY-MM)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
VERSION="$(sed -E 's/^>[[:space:]]+#[[:space:]]+([^[:space:]]+).*$/\1/' <<< "$CHANGELOG_LINE")"
|
||||
RELEASE_DATE="$(sed -E 's/^>[[:space:]]+#[[:space:]]+[^[:space:]]+[[:space:]]+\(([0-9]{4}-[0-9]{2})\).*$/\1/' <<< "$CHANGELOG_LINE")"
|
||||
|
||||
# Extract the complete latest dated release section, excluding
|
||||
# any older releases. Stops at the next top-level version heading.
|
||||
RELEASE_SECTION="$(
|
||||
awk -v heading="$CHANGELOG_LINE" '
|
||||
$0 == heading { found=1; print; next }
|
||||
found && /^>[[:space:]]+#[[:space:]]+/ { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md
|
||||
)"
|
||||
|
||||
# Keep the values safe for filenames/tags.
|
||||
SAFE_VERSION="$(tr -cd '[:alnum:]._-v' <<< "$VERSION")"
|
||||
|
||||
if [[ -z "$SAFE_VERSION" || -z "$RELEASE_DATE" || -z "$RELEASE_SECTION" ]]; then
|
||||
echo "::error::Could not extract the latest dated release section from CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version=$SAFE_VERSION"
|
||||
echo "date=$RELEASE_DATE"
|
||||
echo "heading=$CHANGELOG_LINE"
|
||||
echo "section<<CHANGELOG_EOF"
|
||||
echo "$RELEASE_SECTION"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Latest dated changelog version: $SAFE_VERSION ($RELEASE_DATE)"
|
||||
|
||||
- name: Generate debug keystore
|
||||
run: |
|
||||
if [[ -f app/debug.keystore ]]; then
|
||||
echo "Using the committed app/debug.keystore — signature stays the same across runs."
|
||||
else
|
||||
echo "::warning::app/debug.keystore not found in the repo — generating a temporary one for this run only. Commit the keystore this workflow uploads as a build artifact to app/debug.keystore so future test builds share one signature and can be installed as updates instead of reinstalls."
|
||||
keytool -genkeypair -v \
|
||||
-keystore app/debug.keystore \
|
||||
-storepass android \
|
||||
-keypass android \
|
||||
-alias androiddebugkey \
|
||||
-keyalg RSA \
|
||||
-keysize 2048 \
|
||||
-validity 10000 \
|
||||
-dname "CN=Android Debug,O=Android,C=US"
|
||||
fi
|
||||
|
||||
# app/build.gradle only defines signingConfigs.debug when this
|
||||
# file exists and is non-empty — without it, the keystore above
|
||||
# is never actually referenced and the build silently falls
|
||||
# back to Gradle's own implicit debug signing config instead.
|
||||
cat > keystore.properties <<EOF
|
||||
signingConfig.storeFile=debug.keystore
|
||||
signingConfig.storePassword=android
|
||||
signingConfig.keyAlias=androiddebugkey
|
||||
signingConfig.keyPassword=android
|
||||
EOF
|
||||
|
||||
- name: Create local.properties
|
||||
run: touch local.properties
|
||||
|
||||
- name: Build foss debug APK
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew assembleFossDebug --stacktrace --no-daemon
|
||||
|
||||
- name: Locate and rename APK
|
||||
id: apk
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.changelog.outputs.version }}
|
||||
RELEASE_DATE: ${{ steps.changelog.outputs.date }}
|
||||
TARGET_ARCH: ${{ github.event_name == 'workflow_dispatch' && inputs.architecture || 'universal' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p dist
|
||||
shopt -s nullglob
|
||||
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
# Scoped to the foss flavor only (assembleFossDebug above), so
|
||||
# this looks directly in that flavor's debug output directory.
|
||||
APK_PATHS=(app/build/outputs/apk/foss/debug/*.apk)
|
||||
|
||||
if (( ${#APK_PATHS[@]} == 0 )); then
|
||||
echo "::error::No debug APKs were produced"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "dir=dist" >> "$GITHUB_OUTPUT"
|
||||
|
||||
SELECTED_APK=""
|
||||
SELECTED_ABI=""
|
||||
SELECTED_FLAVOR=""
|
||||
|
||||
for APK_PATH in "${APK_PATHS[@]}"; do
|
||||
# Flavor is the directory two levels above the APK file
|
||||
# (.../apk/<flavor>/debug/<file>.apk).
|
||||
FLAVOR="$(basename "$(dirname "$(dirname "$APK_PATH")")")"
|
||||
|
||||
# Pull the ABI out of the filename by matching a known ABI
|
||||
# token instead of assuming the default "app-<abi>-debug"
|
||||
# naming — this project's APKs are named "YTDLnis-...".
|
||||
ABI="$(basename "$APK_PATH" .apk | grep -oE 'arm64-v8a|armeabi-v7a|x86_64|x86|universal' || true)"
|
||||
if [[ -z "$ABI" ]]; then
|
||||
ABI="$(basename "$APK_PATH" .apk)"
|
||||
fi
|
||||
|
||||
if [[ "$ABI" == "$TARGET_ARCH" ]]; then
|
||||
SELECTED_APK="$APK_PATH"
|
||||
SELECTED_ABI="$ABI"
|
||||
SELECTED_FLAVOR="$FLAVOR"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$SELECTED_APK" ]]; then
|
||||
echo "::error::No APK found for requested architecture: $TARGET_ARCH"
|
||||
echo "::error::Produced APKs:"
|
||||
printf ' %s\n' "${APK_PATHS[@]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUTPUT_APK="dist/YTDLnis-debug-v${VERSION}-${RELEASE_DATE}-${SHORT_SHA}-${SELECTED_FLAVOR}-${SELECTED_ABI}.apk"
|
||||
cp "$SELECTED_APK" "$OUTPUT_APK"
|
||||
|
||||
echo "architecture=$SELECTED_ABI" >> "$GITHUB_OUTPUT"
|
||||
echo "apk=$OUTPUT_APK" >> "$GITHUB_OUTPUT"
|
||||
echo "Selected architecture: $SELECTED_ABI"
|
||||
echo "Selected APK: $SELECTED_APK"
|
||||
|
||||
- name: Create internal debug release notes
|
||||
id: notes
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.changelog.outputs.version }}
|
||||
RELEASE_DATE: ${{ steps.changelog.outputs.date }}
|
||||
CHANGELOG_HEADING: ${{ steps.changelog.outputs.heading }}
|
||||
CHANGELOG_SECTION: ${{ steps.changelog.outputs.section }}
|
||||
SHORT_SHA: ${{ steps.apk.outputs.short_sha }}
|
||||
ARCHITECTURE: ${{ steps.apk.outputs.architecture }}
|
||||
run: |
|
||||
cat > debug-release-notes.md <<EOF
|
||||
## YTDLnis internal debug build
|
||||
|
||||
**Changelog version:** ${VERSION}
|
||||
**Changelog date:** ${RELEASE_DATE}
|
||||
**Commit:** ${GITHUB_SHA}
|
||||
**Short commit:** ${SHORT_SHA}
|
||||
**Architecture:** ${ARCHITECTURE}
|
||||
|
||||
This is an **internal testing build** generated manually from the repository.
|
||||
|
||||
- It is a debug build.
|
||||
- It is a prerelease.
|
||||
- It does **not** use the production release tag.
|
||||
- The changelog version/date are used for identification only.
|
||||
- Install only for internal testing.
|
||||
|
||||
### Latest dated CHANGELOG.md entry
|
||||
|
||||
${CHANGELOG_SECTION}
|
||||
EOF
|
||||
|
||||
- name: Upload as workflow artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: YTDLnis-debug-foss-v${{ steps.changelog.outputs.version }}-${{ steps.apk.outputs.short_sha }}-${{ steps.apk.outputs.architecture }}
|
||||
path: |
|
||||
${{ steps.apk.outputs.dir }}/*.apk
|
||||
debug-release-notes.md
|
||||
retention-days: 1
|
||||
@ -0,0 +1,105 @@
|
||||
package com.deniscerri.ytdl.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.util.TypedValue
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.android.material.R as MaterialR
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ThemeUtilInstrumentedTest {
|
||||
private val context
|
||||
get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateTheme_automaticPresetResetsLegacyDarkModeAndUsesLightPreset() {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putBoolean("use_theme_presets", true)
|
||||
.putBoolean("theme_preset_auto_mode", true)
|
||||
.putString("theme_preset_light_id", ThemeUtil.ThemePreset.Classic.value)
|
||||
.putString("theme_preset_dark_id", ThemeUtil.ThemePreset.Dark.value)
|
||||
.putString("ytdlnis_theme", "Dark")
|
||||
.commit()
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
|
||||
ActivityScenario.launch(ThemeUtilTestActivity::class.java).use { scenario ->
|
||||
scenario.onActivity { activity ->
|
||||
ThemeUtil.updateTheme(activity)
|
||||
|
||||
assertThat(AppCompatDelegate.getDefaultNightMode())
|
||||
.isEqualTo(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
assertThat(activity.themeColor(MaterialR.attr.colorPrimary))
|
||||
.isEqualTo(Color.parseColor("#639eff"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateTheme_automaticPresetUsesDarkPresetInNightMode() {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putBoolean("use_theme_presets", true)
|
||||
.putBoolean("theme_preset_auto_mode", true)
|
||||
.putString("theme_preset_light_id", ThemeUtil.ThemePreset.Classic.value)
|
||||
.putString("theme_preset_dark_id", ThemeUtil.ThemePreset.Dark.value)
|
||||
.commit()
|
||||
|
||||
ActivityScenario.launch(ThemeUtilDarkTestActivity::class.java).use { scenario ->
|
||||
scenario.onActivity { activity ->
|
||||
ThemeUtil.updateTheme(activity)
|
||||
|
||||
assertThat(AppCompatDelegate.getDefaultNightMode())
|
||||
.isEqualTo(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
assertThat(activity.themeColor(MaterialR.attr.colorSurface))
|
||||
.isEqualTo(Color.parseColor("#131313"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateTheme_darkConcretePresetEnablesNightModeAndAppliesPreset() {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putBoolean("use_theme_presets", true)
|
||||
.putBoolean("theme_preset_auto_mode", false)
|
||||
.putString("theme_preset_concrete_id", ThemeUtil.ThemePreset.Dark.value)
|
||||
.commit()
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
|
||||
ActivityScenario.launch(ThemeUtilTestActivity::class.java).use { scenario ->
|
||||
scenario.onActivity { activity ->
|
||||
ThemeUtil.updateTheme(activity)
|
||||
|
||||
assertThat(AppCompatDelegate.getDefaultNightMode())
|
||||
.isEqualTo(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
assertThat(activity.themeColor(MaterialR.attr.colorSurface))
|
||||
.isEqualTo(Color.parseColor("#131313"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Activity.themeColor(attribute: Int): Int {
|
||||
val value = TypedValue()
|
||||
theme.resolveAttribute(attribute, value, true)
|
||||
return value.data
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name=".util.ThemeUtilTestActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".util.ThemeUtilDarkTestActivity"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@ -0,0 +1,15 @@
|
||||
package com.deniscerri.ytdl.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
|
||||
class ThemeUtilDarkTestActivity : Activity() {
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
val darkConfiguration = Configuration(newBase.resources.configuration).apply {
|
||||
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
|
||||
Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
super.attachBaseContext(newBase.createConfigurationContext(darkConfiguration))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.deniscerri.ytdl.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
|
||||
class ThemeUtilTestActivity : Activity() {
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
val lightConfiguration = Configuration(newBase.resources.configuration).apply {
|
||||
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
|
||||
Configuration.UI_MODE_NIGHT_NO
|
||||
}
|
||||
super.attachBaseContext(newBase.createConfigurationContext(lightConfiguration))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package com.deniscerri.ytdl.ui.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.util.TypedValue
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.deniscerri.ytdl.R
|
||||
import com.deniscerri.ytdl.databinding.ItemAccentBinding
|
||||
import com.deniscerri.ytdl.ui.more.settings.SettingHost
|
||||
import com.deniscerri.ytdl.util.ThemeUtil
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.R as MaterialR
|
||||
|
||||
class AccentAdapter(val host: SettingHost) : RecyclerView.Adapter<AccentAdapter.AccentViewHolder>() {
|
||||
|
||||
class AccentViewHolder(val binding: ItemAccentBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccentViewHolder {
|
||||
val binding = ItemAccentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return AccentViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun getItemCount() = ThemeUtil.availableAccents.size
|
||||
|
||||
override fun onBindViewHolder(holder: AccentViewHolder, position: Int) {
|
||||
val accent = ThemeUtil.availableAccents[position]
|
||||
val context = holder.binding.root.context
|
||||
|
||||
// "Default" is Material You. Only wrap it with real dynamic color when
|
||||
// the device actually supports it — wrapContextIfAvailable() returns the
|
||||
// context unchanged (not BaseTheme) when it doesn't, which would preview
|
||||
// whatever accent happens to currently be active instead of a neutral one.
|
||||
val themedContext = when {
|
||||
accent.value == "Default" && DynamicColors.isDynamicColorAvailable() ->
|
||||
DynamicColors.wrapContextIfAvailable(context)
|
||||
accent.value == "Default" ->
|
||||
ContextThemeWrapper(context, R.style.BaseTheme)
|
||||
else ->
|
||||
ContextThemeWrapper(context, accent.styleResource)
|
||||
}
|
||||
|
||||
val primary = themedContext.colorFromAttr(MaterialR.attr.colorPrimary)
|
||||
val onPrimary = themedContext.colorFromAttr(MaterialR.attr.colorOnPrimary)
|
||||
|
||||
holder.binding.apply {
|
||||
accentName.text = root.context.getString(accent.nameResource)
|
||||
accentCard.setCardBackgroundColor(primary)
|
||||
accentCard.strokeColor = onPrimary
|
||||
accentName.setTextColor(onPrimary)
|
||||
accentCard.rippleColor = ColorStateList.valueOf(onPrimary).withAlpha(40)
|
||||
|
||||
root.setOnClickListener {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(host.getHostContext())
|
||||
preferences.edit { putString("theme_accent", accent.value) }
|
||||
ThemeUtil.updateThemes()
|
||||
host.refreshUI()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.colorFromAttr(attr: Int): Int {
|
||||
val value = TypedValue()
|
||||
theme.resolveAttribute(attr, value, true)
|
||||
return value.data
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package com.deniscerri.ytdl.ui.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.util.TypedValue
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.deniscerri.ytdl.databinding.ItemThemePresetBinding
|
||||
import com.deniscerri.ytdl.ui.more.settings.SettingHost
|
||||
import com.deniscerri.ytdl.util.ThemeUtil
|
||||
import com.google.android.material.R as MaterialR
|
||||
|
||||
/**
|
||||
* Grid adapter for the 12 ported theme presets. Two modes, mirroring the split
|
||||
* BAI has between ThemeAdapter/ThemeSelectionDialogFragment's MODE_APPLY and
|
||||
* MODE_CHOOSE:
|
||||
* - [Mode.APPLY]: tapping a card saves it as the concrete preset immediately
|
||||
* and recreates. Used by the top-level "Theme preset" picker.
|
||||
* - [Mode.CHOOSE]: tapping a card just reports it back through [onChosen]
|
||||
* instead of persisting anything. Used when this adapter is hosted inside
|
||||
* the light/dark pairing dialog, which decides whether the pick belongs in
|
||||
* the light slot or the dark slot.
|
||||
*
|
||||
* Each card previews its preset the same way [AccentAdapter] does: wrap the
|
||||
* preset's style in a [ContextThemeWrapper] and read colorPrimary/colorOnPrimary
|
||||
* back off it.
|
||||
*/
|
||||
class ThemePresetAdapter(
|
||||
private val host: SettingHost?,
|
||||
private val mode: Mode,
|
||||
private val presets: List<ThemeUtil.ThemePreset> = ThemeUtil.availableThemePresets,
|
||||
private val onChosen: ((ThemeUtil.ThemePreset) -> Unit)? = null
|
||||
) : RecyclerView.Adapter<ThemePresetAdapter.ThemePresetViewHolder>() {
|
||||
|
||||
enum class Mode { APPLY, CHOOSE }
|
||||
|
||||
class ThemePresetViewHolder(
|
||||
val binding: ItemThemePresetBinding
|
||||
) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThemePresetViewHolder {
|
||||
val binding = ItemThemePresetBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ThemePresetViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun getItemCount() = presets.size
|
||||
|
||||
override fun onBindViewHolder(holder: ThemePresetViewHolder, position: Int) {
|
||||
val preset = presets[position]
|
||||
val context = holder.binding.root.context
|
||||
val themedContext = ContextThemeWrapper(context, preset.styleResource)
|
||||
|
||||
val primary = themedContext.colorFromAttr(MaterialR.attr.colorPrimary)
|
||||
val onPrimary = themedContext.colorFromAttr(MaterialR.attr.colorOnPrimary)
|
||||
|
||||
holder.binding.apply {
|
||||
presetName.text = context.getString(preset.nameResource)
|
||||
presetCard.setCardBackgroundColor(primary)
|
||||
presetCard.strokeColor = onPrimary
|
||||
presetName.setTextColor(onPrimary)
|
||||
presetCard.rippleColor = ColorStateList.valueOf(onPrimary).withAlpha(40)
|
||||
|
||||
root.setOnClickListener {
|
||||
when (mode) {
|
||||
Mode.APPLY -> {
|
||||
val activity = host?.getHostContext() ?: return@setOnClickListener
|
||||
ThemeUtil.setConcreteThemePreset(activity, preset)
|
||||
ThemeUtil.updateThemes()
|
||||
host.refreshUI()
|
||||
}
|
||||
Mode.CHOOSE -> onChosen?.invoke(preset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.colorFromAttr(attr: Int): Int {
|
||||
val value = TypedValue()
|
||||
theme.resolveAttribute(attr, value, true)
|
||||
return value.data
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="@string/theme_preset_choose_light"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/lightSlotCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:strokeWidth="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lightSlotName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="24dp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="@string/theme_preset_choose_dark"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/darkSlotCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:strokeWidth="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/darkSlotName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="24dp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/accentCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:strokeWidth="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/accentName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="28dp"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Blue" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/presetCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:strokeWidth="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/presetName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="28dp"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Ruby" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@ -0,0 +1,152 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
Ported from BAI's 12 named themes (Theme.java / styles.xml / colors.xml).
|
||||
Two corrections vs. the BAI source:
|
||||
- "Ukrrooter" is flagged isDark=false in BAI's Theme.java, but its actual
|
||||
background (#9600ff) and text (#00ff00) are a saturated dark-mode-style
|
||||
pairing, not a light one — reclassified as dark here.
|
||||
- "Ruby" had textColorPrimary/textColorSecondary swapped in BAI's source
|
||||
(a dark grey as the *primary* text color on a dark background) — swapped
|
||||
back so the higher-contrast color is the main text color.
|
||||
Each style sets the M3 tokens that give it a distinct identity
|
||||
(colorPrimary/onPrimary from BAI's accent, colorSurface from BAI's
|
||||
primary/background, colorOnSurface(Variant) from BAI's text colors);
|
||||
everything else inherits from BaseTheme. colorPrimaryDark mirrors
|
||||
colorPrimary (same as BAI's own "Simple" theme template, which maps
|
||||
both to the same source color) so ThemeUtil.getStyledAppName(), which
|
||||
reads colorPrimaryDark, shows the preset's own color instead of an
|
||||
unresolved inherited default.
|
||||
-->
|
||||
|
||||
<string name="theme_preset_classic">Classic</string>
|
||||
<string name="theme_preset_ruby">Ruby</string>
|
||||
<string name="theme_preset_rena">Rena</string>
|
||||
<string name="theme_preset_ukrrooter">Ukrrooter</string>
|
||||
<string name="theme_preset_amoled">Amoled</string>
|
||||
<string name="theme_preset_pixel">Pixel</string>
|
||||
<string name="theme_preset_fdroid_light">F-Droid Light</string>
|
||||
<string name="theme_preset_dark">Dark</string>
|
||||
<string name="theme_preset_gold">Gold</string>
|
||||
<string name="theme_preset_rena_light">Rena Light</string>
|
||||
<string name="theme_preset_mint">Mint</string>
|
||||
<string name="theme_preset_fdroid_dark">F-Droid Dark</string>
|
||||
|
||||
<string name="use_theme_presets">Use theme presets</string>
|
||||
<string name="use_theme_presets_summary">A curated set of full color themes, ported from BAI, instead of accent color + light/dark</string>
|
||||
<string name="theme_preset_auto_mode">Automatic light/dark presets</string>
|
||||
<string name="theme_preset_auto_mode_summary">Switch between a chosen light preset and a chosen dark preset based on system light/dark mode</string>
|
||||
<string name="theme_preset_concrete">Theme preset</string>
|
||||
<string name="theme_preset_light_dark">Light/dark presets</string>
|
||||
<string name="theme_preset_choose_title">Choose a theme</string>
|
||||
<string name="theme_preset_choose_light">Light preset</string>
|
||||
<string name="theme_preset_choose_dark">Dark preset</string>
|
||||
|
||||
<style name="ThemePreset.Classic" parent="BaseTheme">
|
||||
<item name="colorPrimary">#639eff</item>
|
||||
<item name="colorPrimaryDark">#639eff</item>
|
||||
<item name="colorOnPrimary">#000000</item>
|
||||
<item name="colorSurface">#ffffff</item>
|
||||
<item name="colorOnSurface">#444444</item>
|
||||
<item name="colorOnSurfaceVariant">#444444</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemePreset.Ruby" parent="BaseTheme">
|
||||
<item name="colorPrimary">#ffbcbc</item>
|
||||
<item name="colorPrimaryDark">#ffbcbc</item>
|
||||
<item name="colorOnPrimary">#000000</item>
|
||||
<item name="colorSurface">#343944</item>
|
||||
<item name="colorOnSurface">#e5e5e5</item>
|
||||
<item name="colorOnSurfaceVariant">#c9cacb</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemePreset.Rena" parent="BaseTheme">
|
||||
<item name="colorPrimary">#e52c5f</item>
|
||||
<item name="colorPrimaryDark">#e52c5f</item>
|
||||
<item name="colorOnPrimary">#000000</item>
|
||||
<item name="colorSurface">#212735</item>
|
||||
<item name="colorOnSurface">#f7f4f4</item>
|
||||
<item name="colorOnSurfaceVariant">#c9cacb</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemePreset.Ukrrooter" parent="BaseTheme">
|
||||
<item name="colorPrimary">#00ff00</item>
|
||||
<item name="colorPrimaryDark">#00ff00</item>
|
||||
<item name="colorOnPrimary">#000000</item>
|
||||
<item name="colorSurface">#8a00ee</item>
|
||||
<item name="colorOnSurface">#00ff00</item>
|
||||
<item name="colorOnSurfaceVariant">#00ff00</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemePreset.Amoled" parent="BaseTheme">
|
||||
<item name="colorPrimary">#ffffff</item>
|
||||
<item name="colorPrimaryDark">#ffffff</item>
|
||||
<item name="colorOnPrimary">#000000</item>
|
||||
<item name="colorSurface">#000000</item>
|
||||
<item name="colorOnSurface">#ffffff</item>
|
||||
<item name="colorOnSurfaceVariant">#ffffff</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemePreset.Pixel" parent="BaseTheme">
|
||||
<item name="colorPrimary">#448aff</item>
|
||||
<item name="colorPrimaryDark">#448aff</item>
|
||||
<item name="colorOnPrimary">#000000</item>
|
||||
<item name="colorSurface">#ffffff</item>
|
||||
<item name="colorOnSurface">#de000000</item>
|
||||
<item name="colorOnSurfaceVariant">#de000000</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemePreset.FDroidLight" parent="BaseTheme">
|
||||
<item name="colorPrimary">#8bc34a</item>
|
||||
<item name="colorPrimaryDark">#8bc34a</item>
|
||||
<item name="colorOnPrimary">#000000</item>
|
||||
<item name="colorSurface">#ffffff</item>
|
||||
<item name="colorOnSurface">#444444</item>
|
||||
<item name="colorOnSurfaceVariant">#444444</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemePreset.Dark" parent="BaseTheme">
|
||||
<item name="colorPrimary">#71AAEB</item>
|
||||
<item name="colorPrimaryDark">#71AAEB</item>
|
||||
<item name="colorOnPrimary">#000000</item>
|
||||
<item name="colorSurface">#131313</item>
|
||||
<item name="colorOnSurface">#ffffff</item>
|
||||
<item name="colorOnSurfaceVariant">#e0e0e0</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemePreset.Gold" parent="BaseTheme">
|
||||
<item name="colorPrimary">#e9b115</item>
|
||||
<item name="colorPrimaryDark">#e9b115</item>
|
||||
<item name="colorOnPrimary">#000000</item>
|
||||
<item name="colorSurface">#000d26</item>
|
||||
<item name="colorOnSurface">#e6e6e6</item>
|
||||
<item name="colorOnSurfaceVariant">#d9d9d9</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemePreset.RenaLight" parent="BaseTheme">
|
||||
<item name="colorPrimary">#e52c5f</item>
|
||||
<item name="colorPrimaryDark">#e52c5f</item>
|
||||
<item name="colorOnPrimary">#000000</item>
|
||||
<item name="colorSurface">#ffffff</item>
|
||||
<item name="colorOnSurface">#444444</item>
|
||||
<item name="colorOnSurfaceVariant">#444444</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemePreset.Mint" parent="BaseTheme">
|
||||
<item name="colorPrimary">#86E2B3</item>
|
||||
<item name="colorPrimaryDark">#86E2B3</item>
|
||||
<item name="colorOnPrimary">#000000</item>
|
||||
<item name="colorSurface">#141F33</item>
|
||||
<item name="colorOnSurface">#CAD7E0</item>
|
||||
<item name="colorOnSurfaceVariant">#C3D0D9</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemePreset.FDroidDark" parent="BaseTheme">
|
||||
<item name="colorPrimary">#8bc34a</item>
|
||||
<item name="colorPrimaryDark">#8bc34a</item>
|
||||
<item name="colorOnPrimary">#000000</item>
|
||||
<item name="colorSurface">#1C1D21</item>
|
||||
<item name="colorOnSurface">#EDEDED</item>
|
||||
<item name="colorOnSurfaceVariant">#E6E6E6</item>
|
||||
</style>
|
||||
</resources>
|
||||
Loading…
Reference in New Issue