Fix reordering the nav drawer items not always saving the changes (#898)

## Description
Fixes the nav drawer preference reordering & save logic. Also improves
the UI a bit with animations.

It's also now stored in memory and saved when the dialog is dismissed.

### Related issues
Related to #886

### Testing
Emulator mostly

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-02-14 16:13:45 -05:00 committed by GitHub
parent 44eb50910d
commit 0276c5f49b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 128 additions and 107 deletions

View file

@ -15,7 +15,9 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -26,17 +28,44 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.datastore.core.DataStore
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.ListItem import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Switch import androidx.tv.material3.Switch
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavDrawerItemState
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import javax.inject.Inject
data class NavDrawerPin( data class NavDrawerPin(
val id: String, val id: String,
@ -83,11 +112,11 @@ private fun <T> List<T>.move(
fun NavDrawerPreference( fun NavDrawerPreference(
title: String, title: String,
summary: String?, summary: String?,
items: List<NavDrawerPin>,
onSave: (List<NavDrawerPin>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
viewModel: NavDrawerPreferencesViewModel = hiltViewModel(),
) { ) {
val items by viewModel.state.collectAsState()
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
ClickPreference( ClickPreference(
title = title, title = title,
@ -99,19 +128,22 @@ fun NavDrawerPreference(
if (showDialog) { if (showDialog) {
NavDrawerPreferenceDialog( NavDrawerPreferenceDialog(
items = items, items = items,
onDismissRequest = { showDialog = false }, onDismissRequest = {
viewModel.save()
showDialog = false
},
onClick = { index -> onClick = { index ->
val newItems = val newItems =
items.toMutableList().apply { items.toMutableList().apply {
set(index, items[index].let { it.copy(pinned = !it.pinned) }) set(index, items[index].let { it.copy(pinned = !it.pinned) })
} }
onSave.invoke(newItems) viewModel.update(newItems)
}, },
onMoveUp = { index -> onMoveUp = { index ->
onSave(items.move(MoveDirection.UP, index)) viewModel.update(items.move(MoveDirection.UP, index))
}, },
onMoveDown = { index -> onMoveDown = { index ->
onSave(items.move(MoveDirection.DOWN, index)) viewModel.update(items.move(MoveDirection.DOWN, index))
}, },
) )
} }
@ -127,9 +159,12 @@ fun NavDrawerPreferenceDialog(
) { ) {
BasicDialog( BasicDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
elevation = 3.dp,
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier =
Modifier
.padding(16.dp),
) { ) {
Text( Text(
text = stringResource(R.string.nav_drawer_pins), text = stringResource(R.string.nav_drawer_pins),
@ -137,7 +172,9 @@ fun NavDrawerPreferenceDialog(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 8.dp), modifier = Modifier.padding(bottom = 8.dp),
) )
val listState = rememberLazyListState()
LazyColumn( LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
) { ) {
itemsIndexed(items, key = { _, item -> item.id }) { index, item -> itemsIndexed(items, key = { _, item -> item.id }) { index, item ->
@ -149,7 +186,7 @@ fun NavDrawerPreferenceDialog(
onClick = { onClick.invoke(index) }, onClick = { onClick.invoke(index) },
onMoveUp = { onMoveUp.invoke(index) }, onMoveUp = { onMoveUp.invoke(index) },
onMoveDown = { onMoveDown.invoke(index) }, onMoveDown = { onMoveDown.invoke(index) },
modifier = Modifier, modifier = Modifier.animateItem(),
) )
} }
} }
@ -243,3 +280,86 @@ fun NavDrawerPreferenceListItemPreview() {
) )
} }
} }
@HiltViewModel
class NavDrawerPreferencesViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
val preferenceDataStore: DataStore<AppPreferences>,
val navigationManager: NavigationManager,
val backdropService: BackdropService,
private val rememberTabManager: RememberTabManager,
private val serverRepository: ServerRepository,
private val navDrawerService: NavDrawerService,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
) : ViewModel() {
val state = MutableStateFlow<List<NavDrawerPin>>(listOf())
init {
viewModelScope.launchDefault {
val state = navDrawerService.state.value
val user = serverRepository.currentUser.value
val seerr = seerrServerRepository.active.firstOrNull()
if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) {
return@launchDefault
}
val navDrawerPins =
withContext(Dispatchers.IO) {
serverPreferencesDao
.getNavDrawerPinnedItems(user)
.associateBy { it.itemId }
}
val allItems = state.let { it.items + it.moreItems }
val pins =
allItems
.sortedBy {
navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE
}.mapNotNull {
if (!seerr && it is NavDrawerItem.Discover) {
null
} else {
// Assume pinned if unknown
val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
NavDrawerPin(
it.id,
it.name(context),
pinned == NavPinType.PINNED,
it,
)
}
}
this@NavDrawerPreferencesViewModel.state.value = pins
}
}
fun update(items: List<NavDrawerPin>) {
state.update { items }
}
fun save() {
viewModelScope.launchIO(ExceptionHandler(true)) {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUserDto.value?.let { userDto ->
if (user.id == userDto.id) {
val toSave =
state.value.mapIndexed { index, item ->
NavDrawerPinnedItem(
user.rowId,
item.id,
if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED,
index,
)
}
serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray())
navDrawerService.updateNavDrawer(user, userDto)
} else {
throw IllegalStateException("User IDs do not match")
}
}
}
}
}
}

View file

@ -89,7 +89,6 @@ fun PreferencesContent(
val currentServer by seerrVm.currentSeerrServer.collectAsState(null) val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
var showPinFlow by remember { mutableStateOf(false) } var showPinFlow by remember { mutableStateOf(false) }
val navDrawerPins by viewModel.navDrawerPins.collectAsState(emptyList())
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) } var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) }
@ -338,10 +337,6 @@ fun PreferencesContent(
NavDrawerPreference( NavDrawerPreference(
title = stringResource(pref.title), title = stringResource(pref.title),
summary = pref.summary(context, null), summary = pref.summary(context, null),
items = navDrawerPins,
onSave = {
viewModel.updatePins(it)
},
modifier = Modifier, modifier = Modifier,
interactionSource = interactionSource, interactionSource = interactionSource,
) )

View file

@ -5,37 +5,27 @@ import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RememberTabManager import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -49,52 +39,11 @@ class PreferencesViewModel
val backdropService: BackdropService, val backdropService: BackdropService,
private val rememberTabManager: RememberTabManager, private val rememberTabManager: RememberTabManager,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val navDrawerService: NavDrawerService,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
private val deviceInfo: DeviceInfo, private val deviceInfo: DeviceInfo,
private val clientInfo: ClientInfo, private val clientInfo: ClientInfo,
) : ViewModel(), ) : ViewModel(),
RememberTabManager by rememberTabManager { RememberTabManager by rememberTabManager {
val navDrawerPins =
navDrawerService.state
.combine(
serverRepository.currentUser.asFlow(),
) { state, user ->
Pair(state, user)
}.combine(seerrServerRepository.active) { (state, user), seerr ->
Triple(state, user, seerr)
}.map { (state, user, seerr) ->
withContext(Dispatchers.IO) {
val navDrawerPins =
serverPreferencesDao
.getNavDrawerPinnedItems(user!!)
.associateBy { it.itemId }
val allItems = state.let { it.items + it.moreItems }
val pins =
allItems
.sortedBy {
navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE
}.mapNotNull {
if (!seerr && it is NavDrawerItem.Discover) {
null
} else {
// Assume pinned if unknown
val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
NavDrawerPin(
it.id,
it.name(context),
pinned == NavPinType.PINNED,
it,
)
}
}
pins
}
}
val currentUser get() = serverRepository.currentUser val currentUser get() = serverRepository.currentUser
val seerrEnabled = val seerrEnabled =
@ -113,49 +62,6 @@ class PreferencesViewModel
} }
} }
private suspend fun fetchNavDrawerPins(user: JellyfinUser) {
navDrawerService.state.map {
val navDrawerPins =
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
val allItems = navDrawerService.state.first().let { it.items + it.moreItems }
val pins =
allItems
.sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE }
.map {
// Assume pinned if unknown
val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
NavDrawerPin(it.id, it.name(context), pinned == NavPinType.PINNED, it)
}
pins
}
}
fun updatePins(items: List<NavDrawerPin>) {
viewModelScope.launchIO(ExceptionHandler(true)) {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUserDto.value?.let { userDto ->
if (user.id == userDto.id) {
Timber.v("Updating pins")
val toSave =
items.mapIndexed { index, item ->
NavDrawerPinnedItem(
user.rowId,
item.id,
if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED,
index,
)
}
serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray())
navDrawerService.updateNavDrawer(user, userDto)
} else {
throw IllegalStateException("User IDs do not match")
}
}
}
}
}
fun sendAppLogs() { fun sendAppLogs() {
sendAppLogs(context, api, clientInfo, deviceInfo) sendAppLogs(context, api, clientInfo, deviceInfo)
} }