Fixing Settings sticky header text overlapping with menu items (#928)

## Description
- Adding a background for the Settings stickyHeader. This helps with
visibility and prevents overlap with the menu items when scrolling.
### Related issues
In the Settings screen, the header can overlap the text of the menu
items when scrolling down. This is a minor UI bug only and does not
affect functionality at all, but the fix seemed easy and harmless so I
figured I'd give it a go.

### Testing
Tested manually on TCL model 50S434 running Android TV. Tested manually
using AVD emulation on Television (1080p) and Television (4K) preset
devices. Reproduced the problem on all 3 devices and confirmed that my
fix works across all.

## Screenshots
Before:
<img width="1920" height="1080" alt="before-settings-fix"
src="https://github.com/user-attachments/assets/15f9916d-2583-4dc4-8302-cb1f3a395b1a"
/>
After:
<img width="1920" height="1080" alt="after-settings-fix"
src="https://github.com/user-attachments/assets/3f8781aa-7063-4eb4-bf4a-95ff34b9b1a1"
/>


## AI or LLM usage
None

---------

Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com>
This commit is contained in:
Cristiano Chelotti 2026-02-20 19:24:35 -05:00 committed by GitHub
parent 7939fffd48
commit 55b148971e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -11,8 +11,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -166,308 +168,310 @@ fun PreferencesContent(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.tryRequestFocus() focusRequester.tryRequestFocus()
} }
LazyColumn( Column(
state = state,
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(0.dp),
contentPadding = PaddingValues(16.dp),
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
) { ) {
stickyHeader { Text(
Text( text = stringResource(screenTitle),
text = stringResource(screenTitle), style = MaterialTheme.typography.headlineSmall,
style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center,
textAlign = TextAlign.Center, modifier =
modifier = Modifier
Modifier .fillMaxWidth()
.fillMaxWidth() .padding(vertical = 8.dp),
.padding(vertical = 8.dp), )
) LazyColumn(
} state = state,
if (UpdateChecker.ACTIVE && horizontalAlignment = Alignment.Start,
preferenceScreenOption == PreferenceScreenOption.BASIC && verticalArrangement = Arrangement.spacedBy(0.dp),
preferences.autoCheckForUpdates && contentPadding = PaddingValues(16.dp),
updateAvailable modifier = Modifier.fillMaxSize(),
) { ) {
item { if (UpdateChecker.ACTIVE &&
val updateFocusRequester = remember { FocusRequester() } preferenceScreenOption == PreferenceScreenOption.BASIC &&
LaunchedEffect(Unit) { preferences.autoCheckForUpdates &&
if (focusedIndex.first == 0 && focusedIndex.second == 0) { updateAvailable
// Only re-focus if the user hasn't moved ) {
updateFocusRequester.tryRequestFocus()
}
}
ClickPreference(
title = stringResource(R.string.install_update),
onClick = {
if (movementSounds) playOnClickSound(context)
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
},
summary = release?.version?.toString(),
modifier =
Modifier
.focusRequester(updateFocusRequester)
.playSoundOnFocus(movementSounds),
)
}
}
prefList.forEachIndexed { groupIndex, group ->
item {
Text(
text = stringResource(group.title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Start,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
)
}
val groupPreferences =
group.preferences +
group.conditionalPreferences
.filter { it.condition.invoke(preferences) }
.map { it.preferences }
.flatten()
groupPreferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<AppPreferences, Any>
item { item {
val interactionSource = remember { MutableInteractionSource() } val updateFocusRequester = remember { FocusRequester() }
val focused = interactionSource.collectIsFocusedAsState().value LaunchedEffect(Unit) {
LaunchedEffect(focused) { if (focusedIndex.first == 0 && focusedIndex.second == 0) {
if (focused) { // Only re-focus if the user hasn't moved
focusedIndex = Pair(groupIndex, prefIndex) updateFocusRequester.tryRequestFocus()
if (movementSounds) playOnClickSound(context)
onFocus.invoke(groupIndex, prefIndex)
} }
} }
when (pref) { ClickPreference(
AppPreference.InstalledVersion -> { title = stringResource(R.string.install_update),
var clickCount by remember { mutableIntStateOf(0) } onClick = {
ClickPreference( if (movementSounds) playOnClickSound(context)
title = stringResource(R.string.installed_version), viewModel.navigationManager.navigateTo(Destination.UpdateApp)
onClick = { },
if (movementSounds) playOnClickSound(context) summary = release?.version?.toString(),
if (clickCount++ >= 2) { modifier =
clickCount = 0 Modifier
viewModel.navigationManager.navigateTo(Destination.Debug) .focusRequester(updateFocusRequester)
} .playSoundOnFocus(movementSounds),
}, )
summary = installedVersion.toString(), }
interactionSource = interactionSource, }
modifier = prefList.forEachIndexed { groupIndex, group ->
Modifier item {
.ifElse( Text(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, text = stringResource(group.title),
Modifier.focusRequester(focusRequester), style = MaterialTheme.typography.titleMedium,
), color = MaterialTheme.colorScheme.onSurface,
) textAlign = TextAlign.Start,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
)
}
val groupPreferences =
group.preferences +
group.conditionalPreferences
.filter { it.condition.invoke(preferences) }
.map { it.preferences }
.flatten()
groupPreferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<AppPreferences, Any>
item {
val interactionSource = remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value
LaunchedEffect(focused) {
if (focused) {
focusedIndex = Pair(groupIndex, prefIndex)
if (movementSounds) playOnClickSound(context)
onFocus.invoke(groupIndex, prefIndex)
}
} }
when (pref) {
AppPreference.Update -> { AppPreference.InstalledVersion -> {
ClickPreference( var clickCount by remember { mutableIntStateOf(0) }
title = ClickPreference(
if (release != null && updateAvailable) { title = stringResource(R.string.installed_version),
stringResource(R.string.install_update) onClick = {
} else if (!preferences.autoCheckForUpdates && release == null) { if (movementSounds) playOnClickSound(context)
stringResource(R.string.check_for_updates) if (clickCount++ >= 2) {
} else { clickCount = 0
stringResource(R.string.no_update_available) viewModel.navigationManager.navigateTo(Destination.Debug)
}
}, },
onClick = { summary = installedVersion.toString(),
if (movementSounds) playOnClickSound(context) interactionSource = interactionSource,
if (release != null && updateAvailable) { modifier =
release?.let { Modifier
viewModel.navigationManager.navigateTo(Destination.UpdateApp) .ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
),
)
}
AppPreference.Update -> {
ClickPreference(
title =
if (release != null && updateAvailable) {
stringResource(R.string.install_update)
} else if (!preferences.autoCheckForUpdates && release == null) {
stringResource(R.string.check_for_updates)
} else {
stringResource(R.string.no_update_available)
},
onClick = {
if (movementSounds) playOnClickSound(context)
if (release != null && updateAvailable) {
release?.let {
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
}
} else {
updateVM.init(preferences.updateUrl)
} }
} else {
updateVM.init(preferences.updateUrl)
}
},
onLongClick = {
if (movementSounds) playOnClickSound(context)
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
},
summary =
if (updateAvailable) {
release?.version?.toString()
} else {
null
}, },
interactionSource = interactionSource, onLongClick = {
modifier = if (movementSounds) playOnClickSound(context)
Modifier viewModel.navigationManager.navigateTo(Destination.UpdateApp)
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
),
)
}
AppPreference.ClearImageCache -> {
val summary =
remember(cacheUsage) {
cacheUsage.let {
val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT
val memoryUsedMB =
it.imageMemoryUsed / AppPreference.MEGA_BIT
val memoryMaxMB =
it.imageMemoryMax / AppPreference.MEGA_BIT
"Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb"
}
}
ClickPreference(
title = stringResource(pref.title),
onClick = {
SingletonImageLoader.get(context).let {
it.memoryCache?.clear()
it.diskCache?.clear()
updateCache = true
}
},
modifier = Modifier,
summary = summary,
onLongClick = {},
interactionSource = interactionSource,
)
}
AppPreference.UserPinnedNavDrawerItems -> {
NavDrawerPreference(
title = stringResource(pref.title),
summary = pref.summary(context, null),
modifier = Modifier,
interactionSource = interactionSource,
)
}
AppPreference.SendAppLogs -> {
ClickPreference(
title = stringResource(pref.title),
onClick = {
viewModel.sendAppLogs()
},
modifier = Modifier,
summary = pref.summary(context, null),
onLongClick = {},
interactionSource = interactionSource,
)
}
SubtitleSettings.Reset -> {
ClickPreference(
title = stringResource(pref.title),
onClick = {
viewModel.resetSubtitleSettings()
},
modifier = Modifier,
summary = pref.summary(context, null),
onLongClick = {},
interactionSource = interactionSource,
)
}
AppPreference.RequireProfilePin -> {
SwitchPreference(
title = stringResource(pref.title),
value = currentUser?.pin.isNotNullOrBlank(),
onClick = {
showPinFlow = true
},
summaryOn = stringResource(R.string.enabled),
summaryOff = null,
modifier = Modifier,
)
}
AppPreference.SeerrIntegration -> {
ClickPreference(
title = stringResource(pref.title),
onClick = {
seerrDialogMode =
when (val conn = seerrConnection) {
is SeerrConnectionStatus.Error -> {
SeerrDialogMode.Error(conn.serverUrl, conn.ex)
}
SeerrConnectionStatus.NotConfigured -> {
SeerrDialogMode.Add
}
is SeerrConnectionStatus.Success -> {
SeerrDialogMode.Remove(
conn.current.server.url,
)
}
}
},
modifier = Modifier,
summary =
when (seerrConnection) {
is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server)
SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server)
is SeerrConnectionStatus.Success -> stringResource(R.string.enabled)
}, },
onLongClick = {}, summary =
interactionSource = interactionSource, if (updateAvailable) {
) release?.version?.toString()
} } else {
null
},
interactionSource = interactionSource,
modifier =
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
),
)
}
AppPreference.QuickConnect -> { AppPreference.ClearImageCache -> {
ClickPreference( val summary =
title = stringResource(pref.title), remember(cacheUsage) {
onClick = { cacheUsage.let {
if (currentUser != null) { val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT
viewModel.resetQuickConnectStatus() val memoryUsedMB =
showQuickConnectDialog = true it.imageMemoryUsed / AppPreference.MEGA_BIT
} val memoryMaxMB =
}, it.imageMemoryMax / AppPreference.MEGA_BIT
modifier = Modifier, "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb"
summary = pref.summary(context, null),
onLongClick = {},
interactionSource = interactionSource,
)
}
else -> {
val value = pref.getter.invoke(preferences)
ComposablePreference(
preference = pref,
value = value,
onNavigate = viewModel.navigationManager::navigateTo,
onValueChange = { newValue ->
val validation = pref.validate(newValue)
when (validation) {
is PreferenceValidation.Invalid -> {
// TODO?
Toast
.makeText(
context,
validation.message,
Toast.LENGTH_SHORT,
).show()
} }
}
ClickPreference(
title = stringResource(pref.title),
onClick = {
SingletonImageLoader.get(context).let {
it.memoryCache?.clear()
it.diskCache?.clear()
updateCache = true
}
},
modifier = Modifier,
summary = summary,
onLongClick = {},
interactionSource = interactionSource,
)
}
PreferenceValidation.Valid -> { AppPreference.UserPinnedNavDrawerItems -> {
scope.launch(ExceptionHandler()) { NavDrawerPreference(
preferences = title = stringResource(pref.title),
viewModel.preferenceDataStore.updateData { prefs -> summary = pref.summary(context, null),
pref.setter(prefs, newValue) modifier = Modifier,
} interactionSource = interactionSource,
)
}
AppPreference.SendAppLogs -> {
ClickPreference(
title = stringResource(pref.title),
onClick = {
viewModel.sendAppLogs()
},
modifier = Modifier,
summary = pref.summary(context, null),
onLongClick = {},
interactionSource = interactionSource,
)
}
SubtitleSettings.Reset -> {
ClickPreference(
title = stringResource(pref.title),
onClick = {
viewModel.resetSubtitleSettings()
},
modifier = Modifier,
summary = pref.summary(context, null),
onLongClick = {},
interactionSource = interactionSource,
)
}
AppPreference.RequireProfilePin -> {
SwitchPreference(
title = stringResource(pref.title),
value = currentUser?.pin.isNotNullOrBlank(),
onClick = {
showPinFlow = true
},
summaryOn = stringResource(R.string.enabled),
summaryOff = null,
modifier = Modifier,
)
}
AppPreference.SeerrIntegration -> {
ClickPreference(
title = stringResource(pref.title),
onClick = {
seerrDialogMode =
when (val conn = seerrConnection) {
is SeerrConnectionStatus.Error -> {
SeerrDialogMode.Error(conn.serverUrl, conn.ex)
}
SeerrConnectionStatus.NotConfigured -> {
SeerrDialogMode.Add
}
is SeerrConnectionStatus.Success -> {
SeerrDialogMode.Remove(
conn.current.server.url,
)
}
}
},
modifier = Modifier,
summary =
when (seerrConnection) {
is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server)
SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server)
is SeerrConnectionStatus.Success -> stringResource(R.string.enabled)
},
onLongClick = {},
interactionSource = interactionSource,
)
}
AppPreference.QuickConnect -> {
ClickPreference(
title = stringResource(pref.title),
onClick = {
if (currentUser != null) {
viewModel.resetQuickConnectStatus()
showQuickConnectDialog = true
}
},
modifier = Modifier,
summary = pref.summary(context, null),
onLongClick = {},
interactionSource = interactionSource,
)
}
else -> {
val value = pref.getter.invoke(preferences)
ComposablePreference(
preference = pref,
value = value,
onNavigate = viewModel.navigationManager::navigateTo,
onValueChange = { newValue ->
val validation = pref.validate(newValue)
when (validation) {
is PreferenceValidation.Invalid -> {
// TODO?
Toast
.makeText(
context,
validation.message,
Toast.LENGTH_SHORT,
).show()
}
PreferenceValidation.Valid -> {
scope.launch(ExceptionHandler()) {
preferences =
viewModel.preferenceDataStore.updateData { prefs ->
pref.setter(prefs, newValue)
}
}
} }
} }
} },
}, interactionSource = interactionSource,
interactionSource = interactionSource, modifier =
modifier = Modifier
Modifier .ifElse(
.ifElse( groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, Modifier.focusRequester(focusRequester),
Modifier.focusRequester(focusRequester), ),
), )
) }
} }
} }
} }