diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index 252be150d..62709d7c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -16,9 +16,11 @@ package com.keylesspalace.tusky; import android.app.ActivityManager; +import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.content.res.Configuration; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; @@ -45,6 +47,7 @@ import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.interfaces.AccountSelectionListener; import com.keylesspalace.tusky.interfaces.PermissionRequester; +import com.keylesspalace.tusky.settings.PrefKeys; import com.keylesspalace.tusky.util.ThemeUtils; import java.util.ArrayList; @@ -54,6 +57,7 @@ import java.util.List; import javax.inject.Inject; public abstract class BaseActivity extends AppCompatActivity implements Injectable { + private static final String TAG = "BaseActivity"; @Inject public AccountManager accountManager; @@ -93,6 +97,44 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab requesters = new HashMap<>(); } + @Override + protected void attachBaseContext(Context newBase) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase); + + // Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO + float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F); + + Configuration configuration = newBase.getResources().getConfiguration(); + + // Adjust `fontScale` in the configuration. + // + // You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the + // result of previous adjustments. E.g., going from 100% to 80% to 100% does not return + // you to the original 100%, it leaves it at 80%. + // + // Instead, calculate the new scale from the application context. This is unaffected by + // changes to the base context. It does contain contain any changes to the font scale from + // "Settings > Display > Font size" in the device settings, so scaling performed here + // is in addition to any scaling in the device settings. + Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration(); + + // This only adjusts the fonts, anything measured in `dp` is unaffected by this. + // You can try to adjust `densityDpi` as shown in the commented out code below. This + // works, to a point. However, dialogs do not react well to this. Beyond a certain + // scale (~ 120%) the right hand edge of the dialog will clip off the right of the + // screen. + // + // So for now, just adjust the font scale + // + // val displayMetrics = appContext.resources.displayMetrics + // configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt()) + configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F; + + Context fontScaleContext = newBase.createConfigurationContext(configuration); + + super.attachBaseContext(fontScaleContext); + } + protected boolean requiresLogin() { return true; } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt index 8f6d51e35..b47df1596 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt @@ -95,7 +95,9 @@ class PreferencesActivity : } onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback) - restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false + restartActivitiesOnBackPressedCallback.isEnabled = intent.extras?.getBoolean( + EXTRA_RESTART_ON_BACK + ) ?: savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false } override fun onPreferenceStartFragment( @@ -151,6 +153,10 @@ class PreferencesActivity : restartActivitiesOnBackPressedCallback.isEnabled = true this.restartCurrentActivity() } + PrefKeys.UI_TEXT_SCALE_RATIO -> { + restartActivitiesOnBackPressedCallback.isEnabled = true + this.restartCurrentActivity() + } "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash", "showSelfUsername", "showCardsInTimelines", "confirmReblogs", "confirmFavourites", "enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> { @@ -175,7 +181,8 @@ class PreferencesActivity : override fun androidInjector() = androidInjector companion object { - + @Suppress("unused") + private const val TAG = "PreferencesActivity" const val GENERAL_PREFERENCES = 0 const val ACCOUNT_PREFERENCES = 1 const val NOTIFICATION_PREFERENCES = 2 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index 84ba4c0c2..e2d29d495 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -29,6 +29,7 @@ import com.keylesspalace.tusky.settings.listPreference import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preferenceCategory +import com.keylesspalace.tusky.settings.sliderPreference import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.deserialize @@ -99,6 +100,19 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { preferenceDataStore = localeManager } + sliderPreference { + key = PrefKeys.UI_TEXT_SCALE_RATIO + setDefaultValue(100F) + valueTo = 150F + valueFrom = 50F + stepSize = 5F + setTitle(R.string.pref_ui_text_size) + format = "%.0f%%" + decrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_out) + incrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_in) + icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) + } + listPreference { setDefaultValue("medium") setEntries(R.array.post_text_size_names) diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index a20fb4af0..1a64f69b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -101,4 +101,7 @@ object PrefKeys { const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default. const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" + + /** UI text scaling factor, stored as float, 100 = 100% = no scaling */ + const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio" } diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt index fc7a51c58..720dc817f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt @@ -14,6 +14,7 @@ import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreference +import com.keylesspalace.tusky.view.SliderPreference import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference class PreferenceParent( @@ -43,6 +44,15 @@ inline fun PreferenceParent.emojiPreference(activity: A, builder: EmojiPicke return pref } +inline fun PreferenceParent.sliderPreference( + builder: SliderPreference.() -> Unit +): SliderPreference { + val pref = SliderPreference(context) + builder(pref) + addPref(pref) + return pref +} + inline fun PreferenceParent.switchPreference( builder: SwitchPreference.() -> Unit ): SwitchPreference { diff --git a/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt b/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt new file mode 100644 index 000000000..742a2cd76 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt @@ -0,0 +1,185 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.content.res.TypedArray +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View.VISIBLE +import androidx.appcompat.content.res.AppCompatResources +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import com.google.android.material.slider.LabelFormatter.LABEL_GONE +import com.google.android.material.slider.Slider +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.PrefSliderBinding +import java.lang.Float.max +import java.lang.Float.min + +/** + * Slider preference + * + * Similar to [androidx.preference.SeekBarPreference], but better because: + * + * - Uses a [Slider] instead of a [android.widget.SeekBar]. Slider supports float values, and step sizes + * other than 1. + * - Displays the currently selected value in the Preference's summary, for consistency + * with platform norms. + * - Icon buttons can be displayed at the start/end of the slider. Pressing them will + * increment/decrement the slider by `stepSize`. + * - User can supply a custom formatter to format the summary value + */ +class SliderPreference @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle, + defStyleRes: Int = 0 +) : Preference(context, attrs, defStyleAttr, defStyleRes), + Slider.OnChangeListener, + Slider.OnSliderTouchListener { + + /** Backing property for `value` */ + private var _value = 0F + + /** + * @see Slider.getValue + * @see Slider.setValue + */ + var value: Float = defaultValue + get() = _value + set(v) { + val clamped = max(max(v, valueFrom), min(v, valueTo)) + if (clamped == field) return + _value = clamped + persistFloat(v) + notifyChanged() + } + + /** @see Slider.setValueFrom */ + var valueFrom: Float + + /** @see Slider.setValueTo */ + var valueTo: Float + + /** @see Slider.setStepSize */ + var stepSize: Float + + /** + * Format string to be applied to values before setting the summary. For more control set + * [SliderPreference.formatter] + */ + var format: String = defaultFormat + + /** + * Function that will be used to format the summary. The default formatter formats using the + * value of the [SliderPreference.format] property. + */ + var formatter: (Float) -> String = { format.format(it) } + + /** + * Optional icon to show in a button at the start of the slide. If non-null the button is + * shown. Clicking the button decrements the value by one step. + */ + var decrementIcon: Drawable? = null + + /** + * Optional icon to show in a button at the end of the slider. If non-null the button is + * shown. Clicking the button increments the value by one step. + */ + var incrementIcon: Drawable? = null + + /** View binding */ + private lateinit var binding: PrefSliderBinding + + init { + // Using `widgetLayoutResource` here would be incorrect, as that tries to put the entire + // preference layout to the right of the title and summary. + layoutResource = R.layout.pref_slider + + val a = context.obtainStyledAttributes(attrs, R.styleable.SliderPreference, defStyleAttr, defStyleRes) + + value = a.getFloat(R.styleable.SliderPreference_android_value, defaultValue) + valueFrom = a.getFloat(R.styleable.SliderPreference_android_valueFrom, defaultValueFrom) + valueTo = a.getFloat(R.styleable.SliderPreference_android_valueTo, defaultValueTo) + stepSize = a.getFloat(R.styleable.SliderPreference_android_stepSize, defaultStepSize) + format = a.getString(R.styleable.SliderPreference_format) ?: defaultFormat + + val decrementIconResource = a.getResourceId(R.styleable.SliderPreference_iconStart, -1) + if (decrementIconResource != -1) { + decrementIcon = AppCompatResources.getDrawable(context, decrementIconResource) + } + + val incrementIconResource = a.getResourceId(R.styleable.SliderPreference_iconEnd, -1) + if (incrementIconResource != -1) { + incrementIcon = AppCompatResources.getDrawable(context, incrementIconResource) + } + + a.recycle() + } + + override fun onGetDefaultValue(a: TypedArray, i: Int): Any { + return a.getFloat(i, defaultValue) + } + + override fun onSetInitialValue(defaultValue: Any?) { + value = getPersistedFloat((defaultValue ?: Companion.defaultValue) as Float) + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + binding = PrefSliderBinding.bind(holder.itemView) + + binding.root.isClickable = false + + binding.slider.addOnChangeListener(this) + binding.slider.addOnSliderTouchListener(this) + binding.slider.value = value // sliderValue + binding.slider.valueTo = valueTo + binding.slider.valueFrom = valueFrom + binding.slider.stepSize = stepSize + + // Disable the label, the value is shown in the preference summary + binding.slider.labelBehavior = LABEL_GONE + binding.slider.isEnabled = isEnabled + + binding.summary.visibility = VISIBLE + binding.summary.text = formatter(value) + + decrementIcon?.let { icon -> + binding.decrement.icon = icon + binding.decrement.visibility = VISIBLE + binding.decrement.setOnClickListener { + value -= stepSize + } + } + + incrementIcon?.let { icon -> + binding.increment.icon = icon + binding.increment.visibility = VISIBLE + binding.increment.setOnClickListener { + value += stepSize + } + } + } + + override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { + if (!fromUser) return + binding.summary.text = formatter(value) + } + + override fun onStartTrackingTouch(slider: Slider) { + // Deliberately empty + } + + override fun onStopTrackingTouch(slider: Slider) { + value = slider.value + } + + companion object { + private const val TAG = "SliderPreference" + private const val defaultValueFrom = 0F + private const val defaultValueTo = 1F + private const val defaultValue = 0.5F + private const val defaultStepSize = 0.1F + private const val defaultFormat = "%3.1f" + } +} diff --git a/app/src/main/res/layout/pref_slider.xml b/app/src/main/res/layout/pref_slider.xml new file mode 100644 index 000000000..434a5e98d --- /dev/null +++ b/app/src/main/res/layout/pref_slider.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 45b0c4ec8..5cd9a21ac 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -22,6 +22,16 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 158ecfae3..26af20b90 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -338,6 +338,7 @@ Unlisted Followers-only + UI text size Post text size Smallest