settings: redocument

Redocument the settings module.
This commit is contained in:
Alexander Capehart 2022-12-24 16:12:32 -07:00
parent 18ba845302
commit b086c44b59
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
18 changed files with 253 additions and 176 deletions

View file

@ -75,7 +75,7 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
// Save any pending tab configurations to restore from when this dialog is re-created.
// Save any pending tab configurations to restore if this dialog is re-created.
outState.putInt(KEY_TABS, Tab.toIntCode(tabAdapter.tabs))
}

View file

@ -44,16 +44,16 @@ abstract class PlayingIndicatorAdapter<VH : RecyclerView.ViewHolder> : RecyclerV
override fun getItemCount() = currentList.size
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
if (payloads.isEmpty()) {
// Not updating any indicator-specific things, so delegate to the concrete
// adapter (actually bind the item)
onBindViewHolder(holder, position)
}
// Only try to update the playing indicator if the ViewHolder supports it
if (holder is ViewHolder) {
holder.updatePlayingIndicator(currentList[position] == currentItem, isPlaying)
}
if (payloads.isEmpty()) {
// Not updating any indicator-specific attributes, so delegate to the concrete
// adapter (actually bind the item)
onBindViewHolder(holder, position)
}
}
/**
* Update the currently playing item in the list.

View file

@ -14,7 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// We use a special naming convention for internal fields, disable the lints that check for that.
@file:Suppress("PropertyName", "FunctionName")
package org.oxycblt.auxio.music

View file

@ -139,7 +139,7 @@ fun List<String>.parseMultiValue(settings: Settings) =
*/
fun String.maybeParseSeparators(settings: Settings): List<String> {
// Get the separators the user desires. If null, there's nothing to do.
val separators = settings.separators ?: return listOf(this)
val separators = settings.musicSeparators ?: return listOf(this)
return splitEscaped { separators.contains(it) }
}

View file

@ -55,7 +55,7 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
if (binding.separatorSlash.isChecked) separators += SEPARATOR_SLASH
if (binding.separatorPlus.isChecked) separators += SEPARATOR_PLUS
if (binding.separatorAnd.isChecked) separators += SEPARATOR_AND
settings.separators = separators
settings.musicSeparators = separators
}
}
@ -71,7 +71,7 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
// More efficient to do one iteration through the separator list and initialize
// the corresponding CheckBox for each character instead of doing an iteration
// through the separator list for each CheckBox.
settings.separators?.forEach {
settings.musicSeparators?.forEach {
when (it) {
SEPARATOR_COMMA -> binding.separatorComma.isChecked = true
SEPARATOR_SEMICOLON -> binding.separatorSemicolon.isChecked = true

View file

@ -85,7 +85,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
}
private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, settings: Settings) {
when (settings.actionMode) {
when (settings.playbackBarAction) {
ActionMode.NEXT -> {
binding.playbackSecondaryAction.apply {
setIconResource(R.drawable.ic_skip_next_24)

View file

@ -342,7 +342,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
// Android 13+ leverages custom actions in the notification.
val extraAction =
when (settings.notifAction) {
when (settings.playbackNotificationAction) {
ActionMode.SHUFFLE ->
PlaybackStateCompat.CustomAction.Builder(
PlaybackService.ACTION_INVERT_SHUFFLE,
@ -375,7 +375,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
private fun invalidateSecondaryAction() {
invalidateSessionState()
when (settings.notifAction) {
when (settings.playbackNotificationAction) {
ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.isShuffled)
else -> notification.updateRepeatMode(playbackManager.repeatMode)
}

View file

@ -27,7 +27,6 @@ import androidx.core.net.toUri
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.transition.MaterialFadeThrough
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
@ -41,7 +40,7 @@ import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* A [BottomSheetDialogFragment] that shows Auxio's about screen.
* A [ViewBindingFragment] that displays information about the app and the current music library.
* @author Alexander Capehart (OxygenCobalt)
*/
class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
@ -56,18 +55,21 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
override fun onCreateBinding(inflater: LayoutInflater) = FragmentAboutBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentAboutBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.aboutToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
binding.aboutContents.setOnApplyWindowInsetsListener { view, insets ->
view.updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
insets
}
binding.aboutToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
binding.aboutVersion.text = BuildConfig.VERSION_NAME
binding.aboutCode.setOnClickListener { openLinkInBrowser(LINK_CODEBASE) }
binding.aboutCode.setOnClickListener { openLinkInBrowser(LINK_SOURCE) }
binding.aboutFaq.setOnClickListener { openLinkInBrowser(LINK_FAQ) }
binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) }
// VIEWMODEL SETUP
collectImmediately(musicModel.statistics, ::updateStatistics)
}
@ -86,14 +88,15 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
(statistics?.durationMs ?: 0).formatDurationMs(false))
}
/** Go through the process of opening a [link] in a browser. */
private fun openLinkInBrowser(link: String) {
logD("Opening $link")
/**
* Open the given URI in a web browser.
* @param uri The URL to open.
*/
private fun openLinkInBrowser(uri: String) {
logD("Opening $uri")
val context = requireContext()
val browserIntent =
Intent(Intent.ACTION_VIEW, link.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
Intent(Intent.ACTION_VIEW, uri.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11 seems to now handle the app chooser situations on its own now
@ -124,7 +127,7 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
browserIntent.setPackage(pkgName)
startActivity(browserIntent)
} catch (e: ActivityNotFoundException) {
// Not browser but an app chooser due to OEM garbage
// Not a browser but an app chooser
browserIntent.setPackage(null)
openAppChooser(browserIntent)
}
@ -136,18 +139,24 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
}
}
/**
* Open an app chooser for a given [Intent].
* @param intent The [Intent] to show an app chooser for.
*/
private fun openAppChooser(intent: Intent) {
val chooserIntent =
Intent(Intent.ACTION_CHOOSER)
.putExtra(Intent.EXTRA_INTENT, intent)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(chooserIntent)
}
companion object {
private const val LINK_CODEBASE = "https://github.com/oxygencobalt/Auxio"
private const val LINK_FAQ = "$LINK_CODEBASE/blob/master/info/FAQ.md"
private const val LINK_LICENSES = "$LINK_CODEBASE/blob/master/info/LICENSES.md"
/** The URL to the source code. */
private const val LINK_SOURCE = "https://github.com/oxygencobalt/Auxio"
/** The URL to the FAQ document. */
private const val LINK_FAQ = "$LINK_SOURCE/blob/master/info/FAQ.md"
/** The URL to the licenses document. */
private const val LINK_LICENSES = "$LINK_SOURCE/blob/master/info/LICENSES.md"
}
}

View file

@ -40,12 +40,8 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* Auxio's settings.
*
* This object wraps [SharedPreferences] in a type-safe manner, allowing access to all of the major
* settings that Auxio uses. Mutability is determined by use, as some values are written by
* PreferenceManager and others are written by Auxio's code.
*
* A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings.
* Object mutability
* @author Alexander Capehart (OxygenCobalt)
*/
class Settings(private val context: Context, private val callback: Callback? = null) :
@ -59,8 +55,8 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
/**
* Try to migrate shared preference keys to their new versions. Only intended for use by
* AuxioApp. Compat code will persist for 6 months before being removed.
* Migrate any settings from an old version into their modern counterparts. This can cause
* data loss depending on the feasibility of a migration.
*/
fun migrate() {
if (inner.contains(OldKeys.KEY_ACCENT3)) {
@ -117,7 +113,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
fun Int.migratePlaybackMode() =
when (this) {
// Genre playback mode was retried in 3.0.0
// Genre playback mode was removed in 3.0.0
IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS
IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS
IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS
@ -156,6 +152,10 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/**
* Release this instance and any callbacks held by it. This is not needed if no [Callback]
* was originally attached.
*/
fun release() {
inner.unregisterOnSharedPreferenceChangeListener(this)
}
@ -164,25 +164,27 @@ class Settings(private val context: Context, private val callback: Callback? = n
unlikelyToBeNull(callback).onSettingChanged(key)
}
/** An interface for receiving some preference updates. */
/**
* TODO: Remove this
*/
interface Callback {
fun onSettingChanged(key: String)
}
// --- VALUES ---
/** The current theme */
/** The current theme. Represented by the [AppCompatDelegate] constants. */
val theme: Int
get() =
inner.getInt(
context.getString(R.string.set_key_theme),
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
/** Whether the dark theme should be black or not */
/** Whether to use a black background when a dark theme is currently used. */
val useBlackTheme: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_black_theme), false)
/** The current accent. */
/** The current [Accent] (Color Scheme). */
var accent: Accent
get() =
Accent.from(inner.getInt(context.getString(R.string.set_key_accent), Accent.DEFAULT))
@ -193,7 +195,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** The current library tabs preferred by the user. */
/** The tabs to show in the home UI. */
var libTabs: Array<Tab>
get() =
Tab.fromIntCode(
@ -206,40 +208,40 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** Whether to hide collaborator artists or not. */
/** Whether to hide artists considered "collaborators" from the home UI. */
val shouldHideCollaborators: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_hide_collaborators), false)
/** Whether to round additional UI elements (including album covers) */
/** Whether to round additional UI elements that require album covers to be rounded. */
val roundMode: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_round_mode), false)
/** Which action to display on the playback bar. */
val actionMode: ActionMode
/** The action to display on the playback bar. */
val playbackBarAction: ActionMode
get() =
ActionMode.fromIntCode(
inner.getInt(context.getString(R.string.set_key_bar_action), Int.MIN_VALUE))
?: ActionMode.NEXT
/** The custom action to display in the notification. */
val notifAction: ActionMode
/** The action to display in the playback notification. */
val playbackNotificationAction: ActionMode
get() =
ActionMode.fromIntCode(
inner.getInt(context.getString(R.string.set_key_notif_action), Int.MIN_VALUE))
?: ActionMode.REPEAT
/** Whether to resume playback when a headset is connected (may not work well in all cases) */
/** Whether to start playback when a headset is plugged in. */
val headsetAutoplay: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_headset_autoplay), false)
/** The current ReplayGain configuration */
/** The current ReplayGain configuration. */
val replayGainMode: ReplayGainMode
get() =
ReplayGainMode.fromIntCode(
inner.getInt(context.getString(R.string.set_key_replay_gain), Int.MIN_VALUE))
?: ReplayGainMode.DYNAMIC
/** The current ReplayGain pre-amp configuration */
/** The current ReplayGain pre-amp configuration. */
var replayGainPreAmp: ReplayGainPreAmp
get() =
ReplayGainPreAmp(
@ -253,7 +255,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** What queue to create when a song is selected from the library or search */
/** What MusicParent item to play from when a Song is played from the home view. */
val libPlaybackMode: MusicMode
get() =
MusicMode.fromIntCode(
@ -262,8 +264,8 @@ class Settings(private val context: Context, private val callback: Callback? = n
?: MusicMode.SONGS
/**
* What queue t create when a song is selected from an album/artist/genre. Null means to default
* to the currently shown item.
* What MusicParent item to play from when a Song is played from the detail view.
* Will be null if configured to play from the currently shown item.
*/
val detailPlaybackMode: MusicMode?
get() =
@ -271,18 +273,15 @@ class Settings(private val context: Context, private val callback: Callback? = n
inner.getInt(
context.getString(R.string.set_key_detail_song_playback_mode), Int.MIN_VALUE))
/** Whether shuffle should stay on when a new song is selected. */
/** Whether to keep shuffle on when playing a new Song. */
val keepShuffle: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_keep_shuffle), true)
/** Whether to rewind when the back button is pressed. */
/** Whether to rewind when the skip previous button is pressed before skipping back. */
val rewindWithPrev: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_rewind_prev), true)
/**
* Whether [org.oxycblt.auxio.playback.state.RepeatMode.TRACK] should pause when the track
* repeats
*/
/** Whether a song should pause after every repeat. */
val pauseOnRepeat: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_repeat_pause), false)
@ -290,28 +289,34 @@ class Settings(private val context: Context, private val callback: Callback? = n
val shouldBeObserving: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_observing), false)
/** The strategy used when loading images. */
/** The strategy used when loading album covers. */
val coverMode: CoverMode
get() =
CoverMode.fromIntCode(
inner.getInt(context.getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
?: CoverMode.MEDIA_STORE
/** Whether to load all audio files, even ones not considered music. */
/** Whether to exclude non-music audio files from the music library. */
val excludeNonMusic: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_exclude_non_music), true)
/** Get the list of directories that music should be hidden/loaded from. */
/**
* Set the configuration on how to handle particular directories in the music library.
* @param storageManager [StorageManager] required to parse directories.
* @return The [MusicDirectories] configuration.
*/
fun getMusicDirs(storageManager: StorageManager): MusicDirectories {
val dirs =
(inner.getStringSet(context.getString(R.string.set_key_music_dirs), null) ?: emptySet())
.mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) }
return MusicDirectories(
dirs, inner.getBoolean(context.getString(R.string.set_key_music_dirs_include), false))
}
/** Set the list of directories that music should be hidden/loaded from. */
/**
* Set the configuration on how to handle particular directories in the music library.
* @param musicDirs The new [MusicDirectories] configuration.
*/
fun setMusicDirs(musicDirs: MusicDirectories) {
inner.edit {
putStringSet(
@ -323,8 +328,11 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** The list of separators the user wants to parse by. */
var separators: String?
/**
* A string of characters representing the desired separator characters to denote
* multi-value tags.
*/
var musicSeparators: String?
// Differ from convention and store a string of separator characters instead of an int
// code. This makes it easier to use in Regexes and makes it more extendable.
get() =
@ -336,7 +344,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** The current filter mode of the search tab */
/** The type of Music the search view is currently filtering to. */
var searchFilterMode: MusicMode?
get() =
MusicMode.fromIntCode(
@ -350,7 +358,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** The song sort mode on HomeFragment */
/** The Song [Sort] mode used in the Home UI. */
var libSongSort: Sort
get() =
Sort.fromIntCode(
@ -363,7 +371,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** The album sort mode on HomeFragment */
/** The Album [Sort] mode used in the Home UI. */
var libAlbumSort: Sort
get() =
Sort.fromIntCode(
@ -376,7 +384,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** The artist sort mode on HomeFragment */
/** The Artist [Sort] mode used in the Home UI. */
var libArtistSort: Sort
get() =
Sort.fromIntCode(
@ -389,7 +397,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** The genre sort mode on HomeFragment */
/** The Genre [Sort] mode used in the Home UI. */
var libGenreSort: Sort
get() =
Sort.fromIntCode(
@ -402,7 +410,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** The detail album sort mode */
/** The [Sort] mode used in the Album Detail UI. */
var detailAlbumSort: Sort
get() {
var sort =
@ -425,7 +433,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** The detail artist sort mode */
/** The [Sort] mode used in the Artist Detail UI. */
var detailArtistSort: Sort
get() =
Sort.fromIntCode(
@ -438,7 +446,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** The detail genre sort mode */
/** The [Sort] mode used in the Genre Detail UI. */
var detailGenreSort: Sort
get() =
Sort.fromIntCode(
@ -451,7 +459,7 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
}
/** Cache of the old keys used in Auxio. */
/** Legacy keys that are no longer used, but still have to be migrated. */
private object OldKeys {
const val KEY_ACCENT3 = "auxio_accent"
const val KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION"

View file

@ -26,7 +26,7 @@ import org.oxycblt.auxio.databinding.FragmentSettingsBinding
import org.oxycblt.auxio.shared.ViewBindingFragment
/**
* A container [Fragment] for the settings menu.
* A [Fragment] wrapper containing the preference fragment and a companion Toolbar.
* @author Alexander Capehart (OxygenCobalt)
*/
class SettingsFragment : ViewBindingFragment<FragmentSettingsBinding>() {
@ -40,6 +40,7 @@ class SettingsFragment : ViewBindingFragment<FragmentSettingsBinding>() {
FragmentSettingsBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentSettingsBinding, savedInstanceState: Bundle?) {
// Point AppBarLayout to the preference fragment's RecyclerView.
binding.settingsAppbar.liftOnScrollTargetViewId = androidx.preference.R.id.recycler_view
binding.settingsToolbar.setNavigationOnClickListener { findNavController().navigateUp() }
}

View file

@ -105,23 +105,27 @@ private val ACCENT_PRIMARY_COLORS =
R.color.dynamic_primary)
/**
* The data object for an accent. In the UI this is known as a "Color Scheme." This can be nominally
* used to gleam some attributes about a given color scheme, but this is not recommended. Attributes
* are the better option in nearly all cases.
* The data object for a colored theme to use in the UI. This can be nominally used to gleam some
* attributes about a given color scheme, but this is not recommended. Attributes are the better
* option in nearly all cases.
*
* @property name The name of this accent
* @property theme The theme resource for this accent
* @property blackTheme The black theme resource for this accent
* @property primary The primary color resource for this accent
* @param index The unique number for this particular accent.
* @author Alexander Capehart (OxygenCobalt)
*/
class Accent private constructor(val index: Int) : Item {
/** The name of this [Accent]. */
val name: Int
get() = ACCENT_NAMES[index]
/** The theme resource for this accent. */
val theme: Int
get() = ACCENT_THEMES[index]
/**
* The black theme resource for this accent. Identical to [theme], but with a black
* background.
*/
val blackTheme: Int
get() = ACCENT_BLACK_THEMES[index]
/** The accent's primary color. */
val primary: Int
get() = ACCENT_PRIMARY_COLORS[index]
@ -130,31 +134,40 @@ class Accent private constructor(val index: Int) : Item {
override fun hashCode() = index.hashCode()
companion object {
/**
* Create a new instance.
* @param index The unique number for this particular accent.
* @return A new [Accent] with the specified [index]. If [index] is not within the
* range of valid accents, [index] will be [DEFAULT] instead.
*/
fun from(index: Int): Accent {
if (index > (MAX - 1)) {
logW("Account outside of bounds [idx: $index, max: $MAX]")
return Accent(5)
if (index !in 0 until MAX ) {
logW("Accent is out of bounds [idx: $index]")
return Accent(DEFAULT)
}
return Accent(index)
}
/**
* The default accent.
*/
val DEFAULT =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Use dynamic coloring on devices that support it.
ACCENT_THEMES.lastIndex
} else {
// Use blue everywhere else.
5
}
/**
* The maximum amount of accents that are valid. This excludes the dynamic accent on
* versions that do not support it.
* The amount of valid accents.
*/
val MAX =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ACCENT_THEMES.size
} else {
// Disable the option for a dynamic accent
// Disable the option for a dynamic accent on unsupported devices.
ACCENT_THEMES.size - 1
}
}

View file

@ -29,11 +29,13 @@ import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.inflater
/**
* An adapter that displays the accent palette.
* A [RecyclerView.Adapter] that displays [Accent] choices.
* @param listener A [BasicListListener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class AccentAdapter(private val listener: BasicListListener) :
RecyclerView.Adapter<AccentViewHolder>() {
/** The currently selected [Accent]. */
var selectedAccent: Accent? = null
private set
@ -42,7 +44,7 @@ class AccentAdapter(private val listener: BasicListListener) :
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AccentViewHolder.new(parent)
override fun onBindViewHolder(holder: AccentViewHolder, position: Int) =
throw IllegalStateException()
throw NotImplementedError()
override fun onBindViewHolder(
holder: AccentViewHolder,
@ -50,43 +52,63 @@ class AccentAdapter(private val listener: BasicListListener) :
payloads: MutableList<Any>
) {
val item = Accent.from(position)
if (payloads.isEmpty()) {
// Not a re-selection, re-bind with new data.
holder.bind(item, listener)
}
holder.setSelected(item == selectedAccent)
}
/**
* Update the currently selected [Accent].
* @param accent The new [Accent] to select.
*/
fun setSelectedAccent(accent: Accent) {
if (accent == selectedAccent) return
if (accent == selectedAccent) {
// Nothing to do.
return
}
// Update ViewHolders for the old selected accent and new selected accent.
selectedAccent?.let { old -> notifyItemChanged(old.index, PAYLOAD_SELECTION_CHANGED) }
selectedAccent = accent
notifyItemChanged(accent.index, PAYLOAD_SELECTION_CHANGED)
}
companion object {
val PAYLOAD_SELECTION_CHANGED = Any()
private val PAYLOAD_SELECTION_CHANGED = Any()
}
}
/**
* A [RecyclerView.ViewHolder] that displays an [Accent] choice. Use [new] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class AccentViewHolder private constructor(private val binding: ItemAccentBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: Accent, listener: BasicListListener) {
setSelected(false)
/**
* Bind new data to this instance.
* @param accent The new [Accent] to bind.
* @param listener A [BasicListListener] to bind interactions to.
*/
fun bind(accent: Accent, listener: BasicListListener) {
binding.accent.apply {
backgroundTintList = context.getColorCompat(item.primary)
contentDescription = context.getString(item.name)
setOnClickListener { listener.onClick(accent) }
backgroundTintList = context.getColorCompat(accent.primary)
// Add a Tooltip based on the content description so that the purpose of this
// button can be clear.
contentDescription = context.getString(accent.name)
TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener { listener.onClick(item) }
}
}
/**
* Set whether this [Accent] is selected or not.
* @param isSelected Whether this [Accent] is currently selected.
*/
fun setSelected(isSelected: Boolean) {
binding.accent.apply {
isEnabled = !isSelected
iconTint =
if (isSelected) {
context.getAttrColorCompat(R.attr.colorSurface)
@ -97,6 +119,11 @@ class AccentViewHolder private constructor(private val binding: ItemAccentBindin
}
companion object {
/**
* Create a new instance.
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) = AccentViewHolder(ItemAccentBinding.inflate(parent.context.inflater))
}
}

View file

@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* Dialog responsible for showing the list of accents to select.
* A [ViewBindingDialogFragment] that allows the user to configure the current [Accent].
* @author Alexander Capehart (OxygenCobalt)
*/
class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>(), BasicListListener {
@ -45,12 +45,14 @@ class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>(),
builder
.setTitle(R.string.set_accent)
.setPositiveButton(R.string.lbl_ok) { _, _ ->
if (accentAdapter.selectedAccent != settings.accent) {
logD("Applying new accent")
settings.accent = unlikelyToBeNull(accentAdapter.selectedAccent)
requireActivity().recreate()
if (accentAdapter.selectedAccent == settings.accent) {
// Nothing to do.
return@setPositiveButton
}
logD("Applying new accent")
settings.accent = unlikelyToBeNull(accentAdapter.selectedAccent)
requireActivity().recreate()
dismiss()
}
.setNegativeButton(R.string.lbl_cancel, null)
@ -58,6 +60,7 @@ class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>(),
override fun onBindingCreated(binding: DialogAccentBinding, savedInstanceState: Bundle?) {
binding.accentRecycler.adapter = accentAdapter
// Restore a previous pending accent if possible, otherwise select the current setting.
accentAdapter.setSelectedAccent(
if (savedInstanceState != null) {
Accent.from(savedInstanceState.getInt(KEY_PENDING_ACCENT))
@ -68,6 +71,7 @@ class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>(),
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
// Save any pending accent configuration to restore if this dialog is re-created.
outState.putInt(KEY_PENDING_ACCENT, unlikelyToBeNull(accentAdapter.selectedAccent).index)
}
@ -81,6 +85,6 @@ class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>(),
}
companion object {
const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT"
private const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT"
}
}

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getDimenPixels
/**
* A sub-class of [GridLayoutManager] that automatically sets the spans so that they fit the width
* of the RecyclerView. Adapted from this StackOverflow answer:
* https://stackoverflow.com/a/30256880/14143986
* A [GridLayoutManager] that automatically sets the span size in order to use the most possible
* space in the [RecyclerView].
* Derived from this StackOverflow answer: https://stackoverflow.com/a/30256880/14143986
*/
class AccentGridLayoutManager(
context: Context,
@ -39,7 +39,6 @@ class AccentGridLayoutManager(
// We use 56dp here since that's the rough size of the accent item.
// This will need to be modified if this is used beyond the accent dialog.
private var columnWidth = context.getDimenPixels(R.dimen.size_accent_item)
private var lastWidth = -1
private var lastHeight = -1
@ -48,10 +47,8 @@ class AccentGridLayoutManager(
val totalSpace = width - paddingRight - paddingLeft
spanCount = max(1, totalSpace / columnWidth)
}
lastWidth = width
lastHeight = height
super.onLayoutChildren(recycler, state)
}
}

View file

@ -32,10 +32,10 @@ import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.lazyReflectedField
/**
* @brief An implementation of the built-in list preference, backed with integers.
* An implementation of a list-based preference backed with integers.
*
* This is not implemented automatically, so a preference screen must override it's dialog opening
* code in order to handle the preference.
* The dialog this preference corresponds to is not handled automatically, so a preference screen
* must override onDisplayPreferenceDialog in order to handle it.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@ -47,39 +47,39 @@ constructor(
defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle,
defStyleRes: Int = 0
) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) {
/** The names of each entry. */
val entries: Array<CharSequence>
/** The corresponding integer values for each entry. */
val values: IntArray
private var offValue: Int? = -1
private var icons: TypedArray? = null
private var currentValue: Int? = null
// Reflect into Preference to get the (normally inaccessible) default value.
private val defValue: Int
get() = PREFERENCE_DEFAULT_VALUE_FIELD.get(this) as Int
private var entryIcons: TypedArray? = null
private var offValue: Int? = -1
private var currentValue: Int? = null
init {
val prefAttrs =
context.obtainStyledAttributes(
attrs, R.styleable.IntListPreference, defStyleAttr, defStyleRes)
// Can't piggy-back on ListPreference, we have to instead define our own
// attributes for entires/values.
// Can't depend on ListPreference due to it working with strings we have to instead
// define our own attributes for entries/values.
entries = prefAttrs.getTextArrayOrThrow(R.styleable.IntListPreference_entries)
values =
context.resources.getIntArray(
prefAttrs.getResourceIdOrThrow(R.styleable.IntListPreference_entryValues))
// Additional values: offValue defines an "off" position
// entryIcons defines an additional set of icons to use for each entry.
val iconsId = prefAttrs.getResourceId(R.styleable.IntListPreference_entryIcons, -1)
if (iconsId > -1) {
entryIcons = context.resources.obtainTypedArray(iconsId)
}
// offValue defines an value in which the preference should be disabled.
val offValueId = prefAttrs.getResourceId(R.styleable.IntListPreference_offValue, -1)
if (offValueId > -1) {
offValue = context.getInteger(offValueId)
}
val iconsId = prefAttrs.getResourceId(R.styleable.IntListPreference_entryIcons, -1)
if (iconsId > -1) {
icons = context.resources.obtainTypedArray(iconsId)
}
prefAttrs.recycle()
summaryProvider = IntListSummaryProvider()
@ -89,59 +89,73 @@ constructor(
override fun onSetInitialValue(defaultValue: Any?) {
super.onSetInitialValue(defaultValue)
if (defaultValue != null) {
// If were given a default value, we need to assign it.
setValue(defaultValue as Int)
} else {
currentValue = getPersistedInt(defValue)
// Reflect into Preference to get the (normally inaccessible) default value.
currentValue = getPersistedInt(PREFERENCE_DEFAULT_VALUE_FIELD.get(this) as Int)
}
}
override fun shouldDisableDependents(): Boolean = currentValue == offValue
override fun shouldDisableDependents() = currentValue == offValue
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
val index = getValueIndex()
if (index > -1) {
val resourceId = icons?.getResourceId(index, -1) ?: -1
// If we have a specific icon to use, make sure it is set in the view.
val resourceId = entryIcons?.getResourceId(index, -1) ?: -1
if (resourceId > -1) {
(holder.findViewById(android.R.id.icon) as ImageView).setImageResource(resourceId)
}
}
}
/**
* Get the index of the current value.
* @return The index of the current value within [values], or -1 if the [IntListPreference]
* is not set.
*/
fun getValueIndex(): Int {
val curValue = currentValue
if (curValue != null) {
return values.indexOf(curValue)
}
return -1
}
/** Set a value using the index of it in [values] */
/**
* Set the current value of this preference using it's index.
* @param index The index of the new value within [values]. Must be valid.
*/
fun setValueIndex(index: Int) {
setValue(values[index])
}
private fun setValue(value: Int) {
if (value != currentValue) {
currentValue = value
if (!callChangeListener(value)) {
// Listener rejected the value
return
}
callChangeListener(value)
// Update internal value.
currentValue = value
notifyDependencyChange(shouldDisableDependents())
persistInt(value)
notifyChanged()
}
}
/**
* Copy of ListPreference's [Preference.SummaryProvider] for this [IntListPreference].
*/
private inner class IntListSummaryProvider : SummaryProvider<IntListPreference> {
override fun provideSummary(preference: IntListPreference): CharSequence {
val index = getValueIndex()
if (index != -1) {
// Get the entry corresponding to the currently shown value.
return entries[index]
}

View file

@ -24,7 +24,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
/**
* @brief The companion dialog to [IntListPreference].
* The companion dialog to [IntListPreference]. Use [new] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class IntListPreferenceDialog : PreferenceDialogFragmentCompat() {
@ -34,7 +34,7 @@ class IntListPreferenceDialog : PreferenceDialogFragmentCompat() {
override fun onCreateDialog(savedInstanceState: Bundle?) =
// PreferenceDialogFragmentCompat does not allow us to customize the actual creation
// of the alert dialog, so we have to manually override onCreateDialog and customize it
// of the alert dialog, so we have to override onCreateDialog and create a new dialog
// ourselves.
MaterialAlertDialogBuilder(requireContext(), theme)
.setTitle(listPreference.title)
@ -54,11 +54,18 @@ class IntListPreferenceDialog : PreferenceDialogFragmentCompat() {
}
companion object {
/** The tag to use when instantiating this dialog. */
const val TAG = BuildConfig.APPLICATION_ID + ".tag.INT_PREF"
fun from(pref: IntListPreference): IntListPreferenceDialog {
/**
* Create a new instance.
* @param preference The [IntListPreference] to display.
* @return A new instance.
*/
fun new(preference: IntListPreference): IntListPreferenceDialog {
return IntListPreferenceDialog().apply {
arguments = Bundle().apply { putString(ARG_KEY, pref.key) }
// Populate the key field required by PreferenceDialogFragmentCompat.
arguments = Bundle().apply { putString(ARG_KEY, preference.key) }
}
}
}

View file

@ -19,7 +19,6 @@ package org.oxycblt.auxio.settings.prefs
import android.os.Bundle
import android.view.View
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
@ -35,7 +34,6 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.settings.SettingsFragmentDirections
import org.oxycblt.auxio.shared.NavigationViewModel
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD
@ -43,14 +41,12 @@ import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* The actual fragment containing the settings menu. Inherits [PreferenceFragmentCompat].
* The [PreferenceFragmentCompat] that displays the list of settings.
* @author Alexander Capehart (OxygenCobalt)
*/
@Suppress("UNUSED")
class PreferenceFragment : PreferenceFragmentCompat() {
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -58,10 +54,9 @@ class PreferenceFragment : PreferenceFragmentCompat() {
preferenceManager.onDisplayPreferenceDialogListener = this
preferenceScreen.children.forEach(::setupPreference)
// Make the RecycleView edge-to-edge capable
// Configure the RecyclerView to support edge-to-edge.
view.findViewById<RecyclerView>(androidx.preference.R.id.recycler_view).apply {
clipToPadding = false
setOnApplyWindowInsetsListener { _, insets ->
updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
insets
@ -81,21 +76,22 @@ class PreferenceFragment : PreferenceFragmentCompat() {
is IntListPreference -> {
// Copy the built-in preference dialog launching code into our project so
// we can automatically use the provided preference class.
val dialog = IntListPreferenceDialog.from(preference)
val dialog = IntListPreferenceDialog.new(preference)
dialog.setTargetFragment(this, 0)
dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG)
}
is WrappedDialogPreference -> {
val context = requireContext()
// WrappedDialogPreference cannot launch a dialog on it's own, it has to
// be handled manually.
val directions =
when (preference.key) {
context.getString(R.string.set_key_accent) ->
getString(R.string.set_key_accent) ->
SettingsFragmentDirections.goToAccentDialog()
context.getString(R.string.set_key_lib_tabs) ->
getString(R.string.set_key_lib_tabs) ->
SettingsFragmentDirections.goToTabDialog()
context.getString(R.string.set_key_pre_amp) ->
getString(R.string.set_key_pre_amp) ->
SettingsFragmentDirections.goToPreAmpDialog()
context.getString(R.string.set_key_music_dirs) ->
getString(R.string.set_key_music_dirs) ->
SettingsFragmentDirections.goToMusicDirsDialog()
getString(R.string.set_key_separators) ->
SettingsFragmentDirections.goToSeparatorsDialog()
@ -110,6 +106,9 @@ class PreferenceFragment : PreferenceFragmentCompat() {
override fun onPreferenceTreeClick(preference: Preference): Boolean {
val context = requireContext()
// Hook generic preferences to their specified preferences
// TODO: These seem like good things to put into a side navigation view, if I choose to
// do one.
when (preference.key) {
context.getString(R.string.set_key_save_state) -> {
playbackModel.savePlaybackState { saved ->
@ -125,6 +124,8 @@ class PreferenceFragment : PreferenceFragmentCompat() {
context.getString(R.string.set_key_wipe_state) -> {
playbackModel.wipePlaybackState { wiped ->
if (wiped) {
// Use the nullable context, as we could try to show a toast when this
// fragment is no longer attached.
this.context?.showToast(R.string.lbl_state_wiped)
} else {
this.context?.showToast(R.string.err_did_not_wipe)
@ -134,6 +135,8 @@ class PreferenceFragment : PreferenceFragmentCompat() {
context.getString(R.string.set_key_restore_state) ->
playbackModel.tryRestorePlaybackState { restored ->
if (restored) {
// Use the nullable context, as we could try to show a toast when this
// fragment is no longer attached.
this.context?.showToast(R.string.lbl_state_restored)
} else {
this.context?.showToast(R.string.err_did_not_restore)
@ -151,7 +154,10 @@ class PreferenceFragment : PreferenceFragmentCompat() {
val context = requireActivity()
val settings = Settings(context)
if (!preference.isVisible) return
if (!preference.isVisible) {
// Nothing to do.
return
}
if (preference is PreferenceCategory) {
preference.children.forEach(::setupPreference)
@ -188,15 +194,4 @@ class PreferenceFragment : PreferenceFragmentCompat() {
}
}
}
/** Convert an theme integer into an icon that can be used. */
@DrawableRes
private fun Int.toThemeIcon(): Int {
return when (this) {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> R.drawable.ic_auto_24
AppCompatDelegate.MODE_NIGHT_NO -> R.drawable.ic_light_24
AppCompatDelegate.MODE_NIGHT_YES -> R.drawable.ic_dark_24
else -> R.drawable.ic_auto_24
}
}
}

View file

@ -22,8 +22,9 @@ import android.util.AttributeSet
import androidx.preference.DialogPreference
/**
* Wraps [DialogPreference] as to make it type-distinct from other preferences while also making it
* possible to use in a PreferenceScreen.
* Wraps a [DialogPreference] to be instantiatable. This has no purpose other to ensure that
* custom dialog preferences are handled.
* @author Alexander Capehart (OxygenCobalt)
*/
class WrappedDialogPreference
@JvmOverloads