Swap percent fields for DisplaySizeLevel enum end-to-end

The three v1 free sliders (UI scale, Card size, Spacing) become two
discrete enum-based controls (Card size, Spacing) and a new Text size
control, each selecting one of four DisplaySizeLevel values
(Extra small / Small / Default / Large). UI scale is dropped entirely;
the density override at the MainContent root goes with it.

Proto: replaces ui_scale_percent / card_size_percent / spacing_percent
with text_size_level / card_size_level / spacing_level on InterfacePreferences.
DisplaySizeLevel enum is ordered DEFAULT = 0 so proto-zero-default lands
new installs and any unset upgrader on DEFAULT for free; no upgrade-reset
block needed, no serializer default-setting needed.

AppPreference UiScale / CardSize / Spacing slider objects become three
AppChoicePreference<AppPreferences, DisplaySizeLevel> entries
(TextSize, CardSize, Spacing). DisplaySizeLevels.kt holds the user-facing
display order and the three per-control percent-mapping extensions
(textPercent / cardPercent / spacingPercent).

InterfaceCustomization stores the three level fields and exposes
cardSizePercent / spacingPercent as derived getters off the levels, so
every consumer of cardHeightDp / episodeCardHeightDp / spacingDp stays
unchanged.

DisplaySizePage keeps the preview infrastructure (sample-item viewmodel,
HomePageContent-based canvas, fake nav rail, two-column layout) and
swaps the slider column for three ChoicePreference selectors bound to
the new AppPreferences. The density-override wrap on the preview pane
is removed.

InterfaceHelpersTest withinBoundsOrDefault now exercises SkipForward
since CardSize / Spacing are no longer AppSliderPreference. The two
InterfaceCustomization scaling tests assert against cardSizeLevel = LARGE
(116%) and spacingLevel = LARGE (125%).
This commit is contained in:
Justin Visser 2026-05-28 11:26:27 +02:00
parent b6c4f0dd4f
commit c9433fbf57
10 changed files with 220 additions and 228 deletions

View file

@ -18,7 +18,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleEventEffect
@ -40,7 +39,6 @@ import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
import com.github.damontecres.wholphin.ui.util.scaledDensity
@Composable @Composable
fun MainContent( fun MainContent(
@ -62,14 +60,8 @@ fun MainContent(
// setupNavigationManager.backStack = backStack // setupNavigationManager.backStack = backStack
val interfaceCustomization = val interfaceCustomization =
remember(appPreferences) { InterfaceCustomization(appPreferences) } remember(appPreferences) { InterfaceCustomization(appPreferences) }
val baseDensity = LocalDensity.current
val uiScaleDensity =
remember(baseDensity, interfaceCustomization.uiScalePercent) {
scaledDensity(baseDensity, interfaceCustomization.uiScalePercent)
}
CompositionLocalProvider( CompositionLocalProvider(
LocalInterfaceCustomization provides interfaceCustomization, LocalInterfaceCustomization provides interfaceCustomization,
LocalDensity provides uiScaleDensity,
) { ) {
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,

View file

@ -504,46 +504,67 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val UiScale = val TextSize =
AppSliderPreference<AppPreferences>( AppChoicePreference<AppPreferences, DisplaySizeLevel>(
title = R.string.ui_scale, title = R.string.text_size,
defaultValue = 100, defaultValue = DisplaySizeLevel.DEFAULT,
min = 50, displayValues = R.array.display_size_levels,
max = 150, indexToValue = {
interval = 5, displaySizeLevelDisplayOrder.getOrElse(it) { DisplaySizeLevel.DEFAULT }
getter = { it.interfacePreferences.uiScalePercent.toLong() }, },
setter = { prefs, value -> valueToIndex = {
prefs.updateInterfacePreferences { uiScalePercent = value.toInt() } displaySizeLevelDisplayOrder
.indexOf(it)
.let { idx ->
if (idx >= 0) idx else displaySizeLevelDisplayOrder.indexOf(DisplaySizeLevel.DEFAULT)
}
},
getter = { it.interfacePreferences.textSizeLevel },
setter = { prefs, value ->
prefs.updateInterfacePreferences { textSizeLevel = value }
}, },
summarizer = { it?.let { "$it%" } },
) )
val CardSize = val CardSize =
AppSliderPreference<AppPreferences>( AppChoicePreference<AppPreferences, DisplaySizeLevel>(
title = R.string.card_size, title = R.string.card_size,
defaultValue = 100, defaultValue = DisplaySizeLevel.DEFAULT,
min = 50, displayValues = R.array.display_size_levels,
max = 150, indexToValue = {
interval = 5, displaySizeLevelDisplayOrder.getOrElse(it) { DisplaySizeLevel.DEFAULT }
getter = { it.interfacePreferences.cardSizePercent.toLong() }, },
setter = { prefs, value -> valueToIndex = {
prefs.updateInterfacePreferences { cardSizePercent = value.toInt() } displaySizeLevelDisplayOrder
.indexOf(it)
.let { idx ->
if (idx >= 0) idx else displaySizeLevelDisplayOrder.indexOf(DisplaySizeLevel.DEFAULT)
}
},
getter = { it.interfacePreferences.cardSizeLevel },
setter = { prefs, value ->
prefs.updateInterfacePreferences { cardSizeLevel = value }
}, },
summarizer = { it?.let { "$it%" } },
) )
val Spacing = val Spacing =
AppSliderPreference<AppPreferences>( AppChoicePreference<AppPreferences, DisplaySizeLevel>(
title = R.string.spacing, title = R.string.spacing,
defaultValue = 100, defaultValue = DisplaySizeLevel.DEFAULT,
min = 25, displayValues = R.array.display_size_levels,
max = 175, indexToValue = {
interval = 10, displaySizeLevelDisplayOrder.getOrElse(it) { DisplaySizeLevel.DEFAULT }
getter = { it.interfacePreferences.spacingPercent.toLong() }, },
setter = { prefs, value -> valueToIndex = {
prefs.updateInterfacePreferences { spacingPercent = value.toInt() } displaySizeLevelDisplayOrder
.indexOf(it)
.let { idx ->
if (idx >= 0) idx else displaySizeLevelDisplayOrder.indexOf(DisplaySizeLevel.DEFAULT)
}
},
getter = { it.interfacePreferences.spacingLevel },
setter = { prefs, value ->
prefs.updateInterfacePreferences { spacingLevel = value }
}, },
summarizer = { it?.let { "$it%" } },
) )
val DisplaySizePref = val DisplaySizePref =

View file

@ -100,9 +100,6 @@ class AppPreferencesSerializer
showLogos = AppPreference.ShowLogos.defaultValue showLogos = AppPreference.ShowLogos.defaultValue
clearDisplayToggles() clearDisplayToggles()
addAllDisplayToggles(AppPreference.DisplayTogglesPref.defaultValue) addAllDisplayToggles(AppPreference.DisplayTogglesPref.defaultValue)
uiScalePercent = AppPreference.UiScale.defaultValue.toInt()
cardSizePercent = AppPreference.CardSize.defaultValue.toInt()
spacingPercent = AppPreference.Spacing.defaultValue.toInt()
searchPreferences = searchPreferences =
SearchPreferences SearchPreferences

View file

@ -0,0 +1,33 @@
package com.github.damontecres.wholphin.preferences
val displaySizeLevelDisplayOrder: List<DisplaySizeLevel> =
listOf(
DisplaySizeLevel.EXTRA_SMALL,
DisplaySizeLevel.SMALL,
DisplaySizeLevel.DEFAULT,
DisplaySizeLevel.LARGE,
)
fun DisplaySizeLevel.textPercent(): Int =
when (this) {
DisplaySizeLevel.EXTRA_SMALL -> 88
DisplaySizeLevel.SMALL -> 94
DisplaySizeLevel.LARGE -> 108
DisplaySizeLevel.DEFAULT, DisplaySizeLevel.UNRECOGNIZED -> 100
}
fun DisplaySizeLevel.cardPercent(): Int =
when (this) {
DisplaySizeLevel.EXTRA_SMALL -> 78
DisplaySizeLevel.SMALL -> 90
DisplaySizeLevel.LARGE -> 116
DisplaySizeLevel.DEFAULT, DisplaySizeLevel.UNRECOGNIZED -> 100
}
fun DisplaySizeLevel.spacingPercent(): Int =
when (this) {
DisplaySizeLevel.EXTRA_SMALL -> 75
DisplaySizeLevel.SMALL -> 88
DisplaySizeLevel.LARGE -> 125
DisplaySizeLevel.DEFAULT, DisplaySizeLevel.UNRECOGNIZED -> 100
}

View file

@ -350,15 +350,5 @@ class AppUpgradeHandler
} }
} }
} }
if (previous.isEqualOrBefore(Version.fromString("0.6.4-31-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
uiScalePercent = AppPreference.UiScale.defaultValue.toInt()
cardSizePercent = AppPreference.CardSize.defaultValue.toInt()
spacingPercent = AppPreference.Spacing.defaultValue.toInt()
}
}
}
} }
} }

View file

@ -16,17 +16,13 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -40,37 +36,31 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppChoicePreference
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppSliderPreference import com.github.damontecres.wholphin.preferences.DisplaySizeLevel
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsListItem
import com.github.damontecres.wholphin.ui.main.settings.TitleText import com.github.damontecres.wholphin.ui.main.settings.TitleText
import com.github.damontecres.wholphin.ui.main.settings.settingsWidth import com.github.damontecres.wholphin.ui.main.settings.settingsWidth
import com.github.damontecres.wholphin.ui.preferences.ChoicePreference
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.preferences.SliderPreference
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.scaledDensity
import com.github.damontecres.wholphin.ui.util.withinBoundsOrDefault
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -98,8 +88,6 @@ private val PreviewNavRailIcons: List<ImageVector> =
Icons.Default.Search, Icons.Default.Search,
Icons.Default.Add, Icons.Default.Add,
Icons.Default.Star, Icons.Default.Star,
Icons.Default.PlayArrow,
Icons.Default.ArrowDropDown,
Icons.Default.Settings, Icons.Default.Settings,
) )
@ -163,110 +151,73 @@ fun DisplaySizePage(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val iface = preferences.interfacePreferences val iface = preferences.interfacePreferences
val uiScale = iface.uiScalePercent.withinBoundsOrDefault(AppPreference.UiScale)
val cardSize = iface.cardSizePercent.withinBoundsOrDefault(AppPreference.CardSize)
val spacing = iface.spacingPercent.withinBoundsOrDefault(AppPreference.Spacing)
val posters by previewViewModel.posters.collectAsState() val posters by previewViewModel.posters.collectAsState()
val episodes by previewViewModel.episodes.collectAsState() val episodes by previewViewModel.episodes.collectAsState()
// Freezes the rail width and slider labels so they don't reflow under the user's finger Row(
// while dragging UI scale. horizontalArrangement = Arrangement.spacedBy(8.dp),
val ambientDensity = LocalDensity.current modifier = modifier.fillMaxSize(),
val pageDensity = remember { ambientDensity } ) {
Box(
val context = LocalContext.current modifier =
val systemDensity = Modifier
remember { .width(settingsWidth)
val dm = context.resources.displayMetrics .fillMaxHeight()
Density( .background(color = MaterialTheme.colorScheme.surface)
density = dm.density, .padding(8.dp),
fontScale = context.resources.configuration.fontScale, ) {
DisplaySizeSettingsColumn(
textLevel = iface.textSizeLevel,
cardLevel = iface.cardSizeLevel,
spacingLevel = iface.spacingLevel,
onChangeTextLevel = { value ->
scope.launch {
viewModel.preferenceDataStore.updateData {
it.updateInterfacePreferences { textSizeLevel = value }
}
}
},
onChangeCardLevel = { value ->
scope.launch {
viewModel.preferenceDataStore.updateData {
it.updateInterfacePreferences { cardSizeLevel = value }
}
}
},
onChangeSpacingLevel = { value ->
scope.launch {
viewModel.preferenceDataStore.updateData {
it.updateInterfacePreferences { spacingLevel = value }
}
}
},
) )
} }
val previewDensity =
remember(systemDensity, uiScale) { scaledDensity(systemDensity, uiScale) }
CompositionLocalProvider(LocalDensity provides pageDensity) { Box(
Row( modifier =
horizontalArrangement = Arrangement.spacedBy(8.dp), Modifier
modifier = modifier.fillMaxSize(), .fillMaxHeight()
.weight(1f),
) { ) {
Box( DisplaySizePreview(
modifier = posters = posters,
Modifier episodes = episodes,
.width(settingsWidth) showLogos = iface.showLogos,
.fillMaxHeight() onUpdateBackdrop = previewViewModel::updateBackdrop,
.background(color = MaterialTheme.colorScheme.surface) )
.padding(8.dp),
) {
DisplaySizeSettingsColumn(
uiScale = uiScale,
cardSize = cardSize,
spacing = spacing,
onUiScaleChange = { value ->
scope.launch {
viewModel.preferenceDataStore.updateData {
it.updateInterfacePreferences { uiScalePercent = value }
}
}
},
onCardSizeChange = { value ->
scope.launch {
viewModel.preferenceDataStore.updateData {
it.updateInterfacePreferences { cardSizePercent = value }
}
}
},
onSpacingChange = { value ->
scope.launch {
viewModel.preferenceDataStore.updateData {
it.updateInterfacePreferences { spacingPercent = value }
}
}
},
onClickReset = {
scope.launch {
viewModel.preferenceDataStore.updateData {
it.updateInterfacePreferences {
uiScalePercent = AppPreference.UiScale.defaultValue.toInt()
cardSizePercent = AppPreference.CardSize.defaultValue.toInt()
spacingPercent = AppPreference.Spacing.defaultValue.toInt()
}
}
}
},
)
}
Box(
modifier =
Modifier
.fillMaxHeight()
.weight(1f),
) {
CompositionLocalProvider(LocalDensity provides previewDensity) {
DisplaySizePreview(
posters = posters,
episodes = episodes,
showLogos = iface.showLogos,
onUpdateBackdrop = previewViewModel::updateBackdrop,
)
}
}
} }
} }
} }
@Composable @Composable
private fun DisplaySizeSettingsColumn( private fun DisplaySizeSettingsColumn(
uiScale: Int, textLevel: DisplaySizeLevel,
cardSize: Int, cardLevel: DisplaySizeLevel,
spacing: Int, spacingLevel: DisplaySizeLevel,
onUiScaleChange: (Int) -> Unit, onChangeTextLevel: (DisplaySizeLevel) -> Unit,
onCardSizeChange: (Int) -> Unit, onChangeCardLevel: (DisplaySizeLevel) -> Unit,
onSpacingChange: (Int) -> Unit, onChangeSpacingLevel: (DisplaySizeLevel) -> Unit,
onClickReset: () -> Unit,
) { ) {
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
@ -282,39 +233,25 @@ private fun DisplaySizeSettingsColumn(
.focusRestorer(firstFocus), .focusRestorer(firstFocus),
) { ) {
item { item {
DisplaySizeSlider( LevelChoicePreference(
preference = AppPreference.UiScale, preference = AppPreference.TextSize,
value = uiScale, currentValue = textLevel,
onChange = onUiScaleChange, onChange = onChangeTextLevel,
modifier = Modifier.focusRequester(firstFocus), modifier = Modifier.focusRequester(firstFocus),
) )
} }
item { item {
DisplaySizeSlider( LevelChoicePreference(
preference = AppPreference.CardSize, preference = AppPreference.CardSize,
value = cardSize, currentValue = cardLevel,
onChange = onCardSizeChange, onChange = onChangeCardLevel,
) )
} }
item { item {
DisplaySizeSlider( LevelChoicePreference(
preference = AppPreference.Spacing, preference = AppPreference.Spacing,
value = spacing, currentValue = spacingLevel,
onChange = onSpacingChange, onChange = onChangeSpacingLevel,
)
}
item { HorizontalDivider() }
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.reset_to_defaults),
leadingContent = {
Text(
text = stringResource(R.string.fa_arrows_rotate),
fontFamily = FontAwesome,
)
},
onClick = onClickReset,
) )
} }
} }
@ -322,19 +259,20 @@ private fun DisplaySizeSettingsColumn(
} }
@Composable @Composable
private fun DisplaySizeSlider( private fun LevelChoicePreference(
preference: AppSliderPreference<AppPreferences>, preference: AppChoicePreference<AppPreferences, DisplaySizeLevel>,
value: Int, currentValue: DisplaySizeLevel,
onChange: (Int) -> Unit, onChange: (DisplaySizeLevel) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val levelNames = stringArrayResource(preference.displayValues).toList()
SliderPreference( val selectedIndex = preference.valueToIndex(currentValue)
preference = preference, ChoicePreference(
title = stringResource(preference.title), title = stringResource(preference.title),
summary = preference.summary(context, value.toLong()), summary = levelNames.getOrNull(selectedIndex),
value = value.toLong(), possibleValues = levelNames,
onChange = { onChange(it.toInt()) }, selectedIndex = selectedIndex,
onValueChange = { onChange(preference.indexToValue(it)) },
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -1,9 +1,11 @@
package com.github.damontecres.wholphin.ui.util package com.github.damontecres.wholphin.ui.util
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DisplaySizeLevel
import com.github.damontecres.wholphin.preferences.DisplayToggle import com.github.damontecres.wholphin.preferences.DisplayToggle
import com.github.damontecres.wholphin.preferences.cardPercent
import com.github.damontecres.wholphin.preferences.spacingPercent
import com.github.damontecres.wholphin.ui.BASE_SPACING_DP import com.github.damontecres.wholphin.ui.BASE_SPACING_DP
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import java.util.EnumSet import java.util.EnumSet
@ -13,9 +15,9 @@ import java.util.EnumSet
*/ */
data class InterfaceCustomization( data class InterfaceCustomization(
val enabledDisplayToggles: EnumSet<DisplayToggle>, val enabledDisplayToggles: EnumSet<DisplayToggle>,
val uiScalePercent: Int = AppPreference.UiScale.defaultValue.toInt(), val textSizeLevel: DisplaySizeLevel = DisplaySizeLevel.DEFAULT,
val cardSizePercent: Int = AppPreference.CardSize.defaultValue.toInt(), val cardSizeLevel: DisplaySizeLevel = DisplaySizeLevel.DEFAULT,
val spacingPercent: Int = AppPreference.Spacing.defaultValue.toInt(), val spacingLevel: DisplaySizeLevel = DisplaySizeLevel.DEFAULT,
) { ) {
constructor(prefs: AppPreferences) : this( constructor(prefs: AppPreferences) : this(
enabledDisplayToggles = enabledDisplayToggles =
@ -26,14 +28,17 @@ data class InterfaceCustomization(
EnumSet.copyOf(it) EnumSet.copyOf(it)
} }
}, },
uiScalePercent = textSizeLevel = prefs.interfacePreferences.textSizeLevel,
prefs.interfacePreferences.uiScalePercent.withinBoundsOrDefault(AppPreference.UiScale), cardSizeLevel = prefs.interfacePreferences.cardSizeLevel,
cardSizePercent = spacingLevel = prefs.interfacePreferences.spacingLevel,
prefs.interfacePreferences.cardSizePercent.withinBoundsOrDefault(AppPreference.CardSize),
spacingPercent =
prefs.interfacePreferences.spacingPercent.withinBoundsOrDefault(AppPreference.Spacing),
) )
val cardSizePercent: Int
get() = cardSizeLevel.cardPercent()
val spacingPercent: Int
get() = spacingLevel.spacingPercent()
val cardHeightDp: Int val cardHeightDp: Int
get() = Cards.HEIGHT_2X3_DP * cardSizePercent / 100 get() = Cards.HEIGHT_2X3_DP * cardSizePercent / 100

View file

@ -177,6 +177,13 @@ enum DisplayToggle{
COMMUNITY_RATING = 2; COMMUNITY_RATING = 2;
} }
enum DisplaySizeLevel{
DEFAULT = 0;
EXTRA_SMALL = 1;
SMALL = 2;
LARGE = 3;
}
message InterfacePreferences { message InterfacePreferences {
ThemeSongVolume play_theme_songs = 1; ThemeSongVolume play_theme_songs = 1;
bool remember_selected_tab = 2; bool remember_selected_tab = 2;
@ -193,9 +200,9 @@ message InterfacePreferences {
bool show_logos = 13; bool show_logos = 13;
SearchPreferences search_preferences = 14; SearchPreferences search_preferences = 14;
repeated DisplayToggle display_toggles = 15; repeated DisplayToggle display_toggles = 15;
int32 ui_scale_percent = 16; DisplaySizeLevel text_size_level = 19;
int32 card_size_percent = 17; DisplaySizeLevel card_size_level = 20;
int32 spacing_percent = 18; DisplaySizeLevel spacing_level = 21;
} }
message AdvancedPreferences { message AdvancedPreferences {

View file

@ -399,8 +399,12 @@
<string name="spacing">Spacing</string> <string name="spacing">Spacing</string>
<string name="card_size">Card size</string> <string name="card_size">Card size</string>
<string name="display_size">Display size</string> <string name="display_size">Display size</string>
<string name="display_size_summary">Scale the app UI, card size, and spacing</string> <string name="display_size_summary">Adjust text size, card size, and spacing</string>
<string name="ui_scale">UI scale</string> <string name="text_size">Text size</string>
<string name="display_size_level_extra_small">Extra small</string>
<string name="display_size_level_small">Small</string>
<string name="display_size_level_default">Default</string>
<string name="display_size_level_large">Large</string>
<string name="aspect_ratio">Aspect Ratio</string> <string name="aspect_ratio">Aspect Ratio</string>
<string name="show_details">Show details</string> <string name="show_details">Show details</string>
<string name="image_type">Image type</string> <string name="image_type">Image type</string>
@ -597,6 +601,13 @@
<item>@string/black</item> <item>@string/black</item>
</string-array> </string-array>
<string-array name="display_size_levels">
<item>@string/display_size_level_extra_small</item>
<item>@string/display_size_level_small</item>
<item>@string/display_size_level_default</item>
<item>@string/display_size_level_large</item>
</string-array>
<string name="skip_ignore">Ignore</string> <string name="skip_ignore">Ignore</string>
<string name="skip_automatically">Skip automatically</string> <string name="skip_automatically">Skip automatically</string>
<string name="skip_ask">Ask to skip</string> <string name="skip_ask">Ask to skip</string>

View file

@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DisplaySizeLevel
import com.github.damontecres.wholphin.services.migrateLegacyRowSpacing import com.github.damontecres.wholphin.services.migrateLegacyRowSpacing
import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
@ -16,31 +17,28 @@ import java.util.EnumSet
class InterfaceHelpersTest { class InterfaceHelpersTest {
@Test @Test
fun `withinBoundsOrDefault returns value when in range`() { fun `withinBoundsOrDefault returns value when in range`() {
assertEquals(100, 100.withinBoundsOrDefault(AppPreference.CardSize)) assertEquals(30, 30.withinBoundsOrDefault(AppPreference.SkipForward))
assertEquals(75, 75.withinBoundsOrDefault(AppPreference.CardSize)) assertEquals(60, 60.withinBoundsOrDefault(AppPreference.SkipForward))
assertEquals(150, 150.withinBoundsOrDefault(AppPreference.CardSize)) assertEquals(120, 120.withinBoundsOrDefault(AppPreference.SkipForward))
} }
@Test @Test
fun `withinBoundsOrDefault returns default when below min`() { fun `withinBoundsOrDefault returns default when below min`() {
// CardSize is 50..150 default 100; Spacing is 25..175 default 100. assertEquals(30, 4.withinBoundsOrDefault(AppPreference.SkipForward))
assertEquals(100, 49.withinBoundsOrDefault(AppPreference.CardSize)) assertEquals(30, 0.withinBoundsOrDefault(AppPreference.SkipForward))
assertEquals(100, 0.withinBoundsOrDefault(AppPreference.Spacing))
} }
@Test @Test
fun `withinBoundsOrDefault returns default when above max`() { fun `withinBoundsOrDefault returns default when above max`() {
assertEquals(100, 151.withinBoundsOrDefault(AppPreference.CardSize)) assertEquals(30, 301.withinBoundsOrDefault(AppPreference.SkipForward))
assertEquals(100, 200.withinBoundsOrDefault(AppPreference.Spacing)) assertEquals(30, 1000.withinBoundsOrDefault(AppPreference.SkipForward))
} }
@Test @Test
fun `withinBoundsOrDefault includes both bounds inclusively`() { fun `withinBoundsOrDefault includes both bounds inclusively`() {
// Sanity: min and max themselves are accepted, NOT replaced with default. // Sanity: min and max themselves are accepted, NOT replaced with default.
assertEquals(50, 50.withinBoundsOrDefault(AppPreference.CardSize)) assertEquals(5, 5.withinBoundsOrDefault(AppPreference.SkipForward))
assertEquals(150, 150.withinBoundsOrDefault(AppPreference.CardSize)) assertEquals(300, 300.withinBoundsOrDefault(AppPreference.SkipForward))
assertEquals(25, 25.withinBoundsOrDefault(AppPreference.Spacing))
assertEquals(175, 175.withinBoundsOrDefault(AppPreference.Spacing))
} }
@Test @Test
@ -134,38 +132,38 @@ class InterfaceHelpersTest {
} }
@Test @Test
fun `InterfaceCustomization cardHeightDp computes global from slider percent`() { fun `InterfaceCustomization cardHeightDp at DEFAULT level matches natural base`() {
val customization = InterfaceCustomization(prefs = AppPreferences.getDefaultInstance()) val customization = InterfaceCustomization(prefs = AppPreferences.getDefaultInstance())
// Default cardSizePercent is 100, so cardHeightDp == HEIGHT_2X3_DP. // proto-zero-default lands cardSizeLevel on DEFAULT (100%), so cardHeightDp == HEIGHT_2X3_DP.
assertEquals(Cards.HEIGHT_2X3_DP, customization.cardHeightDp) assertEquals(Cards.HEIGHT_2X3_DP, customization.cardHeightDp)
} }
@Test @Test
fun `InterfaceCustomization cardHeightDp scales linearly`() { fun `InterfaceCustomization cardHeightDp scales by Card level percent`() {
val customization = val customization =
InterfaceCustomization( InterfaceCustomization(
enabledDisplayToggles = enabledDisplayToggles =
EnumSet.noneOf( EnumSet.noneOf(
com.github.damontecres.wholphin.preferences.DisplayToggle::class.java, com.github.damontecres.wholphin.preferences.DisplayToggle::class.java,
), ),
cardSizePercent = 150, cardSizeLevel = DisplaySizeLevel.LARGE,
) )
// 172 * 150 / 100 = 258. // LARGE → cardPercent() = 116. 172 * 116 / 100 = 199 (integer truncation).
assertEquals(258, customization.cardHeightDp) assertEquals(199, customization.cardHeightDp)
} }
@Test @Test
fun `InterfaceCustomization spacingDp scales linearly`() { fun `InterfaceCustomization spacingDp scales by Spacing level percent`() {
val customization = val customization =
InterfaceCustomization( InterfaceCustomization(
enabledDisplayToggles = enabledDisplayToggles =
EnumSet.noneOf( EnumSet.noneOf(
com.github.damontecres.wholphin.preferences.DisplayToggle::class.java, com.github.damontecres.wholphin.preferences.DisplayToggle::class.java,
), ),
spacingPercent = 175, spacingLevel = DisplaySizeLevel.LARGE,
) )
// BASE_SPACING_DP = 16. 16 * 175 / 100 = 28. // LARGE → spacingPercent() = 125. BASE_SPACING_DP = 16. 16 * 125 / 100 = 20.
assertEquals(28, customization.spacingDp) assertEquals(20, customization.spacingDp)
} }
@Test @Test