Add ability to order navigation drawer items (#886)

## Description
Allows for reordering the items in the navigation drawer

This does not change the home page row order since #803 decouples the
nav drawer and home page rows.

The initial order will be the "Library Order" settings on the web under
Profile->Home. Making any changes locally in Wholphin will set the
order. If any new libraries are added or if Seerr integration is
enabled, these will added to the end of the "pinned" list.

### Related issues
Closes #822
Related to #399 & #803

### Testing
Tested on emulator
- Re-ordering
- Granting/Revoking user permission to a new library

## Screenshots
N/A, nav drawer is the same, just sorted differently

## AI or LLM usage
None
This commit is contained in:
Ray 2026-02-12 19:55:12 -05:00 committed by GitHub
parent fcba1c7444
commit 17b0eef5c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1214 additions and 163 deletions

View file

@ -40,7 +40,7 @@ import java.util.UUID
SeerrUser::class,
],
version = 30,
version = 31,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@ -54,6 +54,7 @@ import java.util.UUID
AutoMigration(11, 12),
AutoMigration(12, 20),
AutoMigration(20, 30),
AutoMigration(30, 31),
],
)
@TypeConverters(Converters::class)

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@ -24,4 +25,5 @@ data class NavDrawerPinnedItem(
val userId: Int,
val itemId: String,
val type: NavPinType,
@ColumnInfo(defaultValue = "-1") val order: Int,
)

View file

@ -0,0 +1,147 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.lifecycle.asFlow
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NavDrawerService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:DefaultCoroutineScope private val coroutineScope: CoroutineScope,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
) {
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
val state: StateFlow<NavDrawerItemState> = _state
init {
serverRepository.currentUser
.asFlow()
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
Pair(user, userDto)
}.onEach { (user, userDto) ->
Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)
_state.update {
it.copy(
items = emptyList(),
moreItems = emptyList(),
)
}
if (user != null && userDto != null && user.id == userDto.id) {
updateNavDrawer(user, userDto)
}
}.launchIn(coroutineScope)
seerrServerRepository.active
.onEach { discoverActive ->
_state.update { it.copy(discoverEnabled = discoverActive) }
}.launchIn(coroutineScope)
}
suspend fun updateNavDrawer(
user: JellyfinUser,
userDto: UserDto,
) {
val tvAccess = userDto.policy?.enableLiveTvAccess ?: false
val userViews =
api.userViewsApi
.getUserViews(userId = user.id)
.content.items
val recordingFolders =
if (tvAccess) {
api.liveTvApi
.getRecordingFolders(userId = user.id)
.content.items
.map { it.id }
.toSet()
} else {
setOf()
}
val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
val libraries =
userViews
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
.map {
val destination =
if (it.id in recordingFolders) {
Destination.Recordings(it.id)
} else {
BaseItem.from(it, api).destination()
}
ServerNavDrawerItem(
itemId = it.id,
name = it.name ?: it.id.toString(),
destination = destination,
type = it.collectionType ?: CollectionType.UNKNOWN,
)
}
val allItems = builtins + libraries
val navDrawerPins =
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
val items = mutableListOf<NavDrawerItem>()
val moreItems = mutableListOf<NavDrawerItem>()
allItems
// Sort by order if non-default, existing items before customize will have -1 value
// New items from the server will get Int.MAX_VALUE
// Items the user doesn't have access to anymore will be skipped
.sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE }
.forEach {
// Assume pinned if unknown
val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
if (pinned == NavPinType.PINNED) {
items.add(it)
} else {
moreItems.add(it)
}
}
_state.update {
it.copy(
items = items,
moreItems = moreItems,
)
}
}
}
data class NavDrawerItemState(
val items: List<NavDrawerItem>,
val moreItems: List<NavDrawerItem>,
val discoverEnabled: Boolean,
) {
companion object {
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
}
}

View file

@ -44,6 +44,10 @@ annotation class StandardOkHttpClient
@Retention(AnnotationRetention.BINARY)
annotation class IoCoroutineScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@ -177,6 +181,11 @@ object AppModule {
@IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
@DefaultCoroutineScope
fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun workManager(

View file

@ -27,8 +27,8 @@ import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
@ -54,7 +54,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.DrawerState
import androidx.tv.material3.DrawerValue
import androidx.tv.material3.Icon
@ -72,6 +71,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.preferences.UserPreferences
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.SeerrServerRepository
import com.github.damontecres.wholphin.services.SetupDestination
@ -79,23 +79,16 @@ import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.setup.UserIconCardImage
import com.github.damontecres.wholphin.ui.spacedByWithFooter
import com.github.damontecres.wholphin.ui.theme.LocalTheme
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.CollectionType
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@ -104,80 +97,17 @@ class NavDrawerViewModel
@Inject
constructor(
private val api: ApiClient,
private val navDrawerService: NavDrawerService,
private val navDrawerItemRepository: NavDrawerItemRepository,
val navigationManager: NavigationManager,
val setupNavigationManager: SetupNavigationManager,
val backdropService: BackdropService,
private val seerrServerRepository: SeerrServerRepository,
) : ViewModel() {
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
val state = navDrawerService.state
val selectedIndex = MutableLiveData(-1)
val showMore = MutableLiveData(false)
init {
seerrServerRepository.active
.onEach {
init()
}.launchIn(viewModelScope)
}
fun init() {
viewModelScope.launchIO {
val all = navDrawerItemRepository.getNavDrawerItems()
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
withContext(Dispatchers.Main) {
this@NavDrawerViewModel.moreLibraries.value = moreLibraries
this@NavDrawerViewModel.libraries.value = libraries
}
val asDestinations =
(
libraries +
listOf(
NavDrawerItem.More,
NavDrawerItem.Discover,
) + moreLibraries
).map {
if (it is ServerNavDrawerItem) {
it.destination
} else if (it is NavDrawerItem.Favorites) {
Destination.Favorites
} else if (it is NavDrawerItem.Discover) {
Destination.Discover
} else {
null
}
}
val backstack = navigationManager.backStack.toList().reversed()
for (i in 0..<backstack.size) {
val key = backstack[i]
if (key is Destination) {
val index =
if (key is Destination.Home) {
-1
} else if (key is Destination.Search) {
-2
} else {
val idx = asDestinations.indexOf(key)
if (idx >= 0) {
idx
} else {
null
}
}
Timber.v("Found $index => $key")
if (index != null) {
selectedIndex.setValueOnMain(index)
break
}
}
}
}
}
val moreExpanded = MutableLiveData(false)
fun onClickDrawerItem(
index: Int,
@ -193,7 +123,7 @@ class NavDrawerViewModel
}
NavDrawerItem.More -> {
setShowMore(!showMore.value!!)
setShowMore(!moreExpanded.value!!)
}
NavDrawerItem.Discover -> {
@ -215,7 +145,7 @@ class NavDrawerViewModel
}
fun setShowMore(value: Boolean) {
showMore.value = value
moreExpanded.value = value
}
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
@ -289,15 +219,12 @@ fun NavDrawer(
drawerState.setValue(DrawerValue.Open)
focusRequester.requestFocus()
}
val moreLibraries by viewModel.moreLibraries.observeAsState(listOf())
val libraries by viewModel.libraries.observeAsState(listOf())
LaunchedEffect(Unit) { viewModel.init() }
val showMore by viewModel.showMore.observeAsState(false)
// A negative index is a built in page, >=0 is a library
val state by viewModel.state.collectAsState()
val moreExpanded by viewModel.moreExpanded.observeAsState(false)
// A negative index is a built-in page, >=0 is a library
val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) {
BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) {
viewModel.setShowMore(false)
}
@ -412,51 +339,83 @@ fun NavDrawer(
),
)
}
itemsIndexed(libraries) { index, it ->
val interactionSource = remember { MutableInteractionSource() }
NavItem(
library = it,
selected = selectedIndex == index,
moreExpanded = showMore,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.onClickDrawerItem(index, it)
},
modifier =
Modifier
.ifElse(
selectedIndex == index,
Modifier.focusRequester(focusRequester),
),
)
}
if (showMore) {
itemsIndexed(moreLibraries) { index, it ->
val adjustedIndex = (index + libraries.size + 1)
itemsIndexed(state.items) { index, it ->
if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
val interactionSource = remember { MutableInteractionSource() }
NavItem(
library = it,
selected = selectedIndex == adjustedIndex,
moreExpanded = showMore,
selected = selectedIndex == index,
moreExpanded = moreExpanded,
drawerOpen = isOpen,
onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) },
containerColor =
if (isOpen) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
} else {
Color.Unspecified
},
interactionSource = interactionSource,
onClick = {
viewModel.onClickDrawerItem(index, it)
},
modifier =
Modifier
.ifElse(
selectedIndex == adjustedIndex,
selectedIndex == index,
Modifier.focusRequester(focusRequester),
),
)
}
}
if (state.moreItems.isNotEmpty()) {
item {
val index = state.items.size
val interactionSource = remember { MutableInteractionSource() }
NavItem(
library = NavDrawerItem.More,
selected = selectedIndex == index,
moreExpanded = moreExpanded,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.onClickDrawerItem(index, NavDrawerItem.More)
},
modifier =
Modifier
.ifElse(
selectedIndex == index,
Modifier.focusRequester(focusRequester),
),
)
}
}
if (moreExpanded) {
itemsIndexed(state.moreItems) { index, it ->
val adjustedIndex =
remember(state) { (index + state.items.size + 1) }
if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
val interactionSource = remember { MutableInteractionSource() }
NavItem(
library = it,
selected = selectedIndex == adjustedIndex,
moreExpanded = moreExpanded,
drawerOpen = isOpen,
onClick = {
viewModel.onClickDrawerItem(
adjustedIndex,
it,
)
},
containerColor =
if (isOpen) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
} else {
Color.Unspecified
},
interactionSource = interactionSource,
modifier =
Modifier
.ifElse(
selectedIndex == adjustedIndex,
Modifier.focusRequester(focusRequester),
),
)
}
}
}
item {
val interactionSource = remember { MutableInteractionSource() }
IconNavItem(

View file

@ -0,0 +1,245 @@
package com.github.damontecres.wholphin.ui.preferences
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
data class NavDrawerPin(
val id: String,
val title: String,
val pinned: Boolean,
val item: NavDrawerItem,
) {
companion object {
fun create(
context: Context,
items: Map<NavDrawerItem, Boolean>,
) {
items.map { (item, pinned) ->
NavDrawerPin(item.id, item.name(context), pinned, item)
}
}
}
}
enum class MoveDirection {
UP,
DOWN,
}
private fun <T> List<T>.move(
direction: MoveDirection,
index: Int,
): List<T> =
toMutableList().apply {
if (direction == MoveDirection.DOWN) {
val down = this[index]
val up = this[index + 1]
set(index, up)
set(index + 1, down)
} else {
val up = this[index]
val down = this[index - 1]
set(index - 1, up)
set(index, down)
}
}
@Composable
fun NavDrawerPreference(
title: String,
summary: String?,
items: List<NavDrawerPin>,
onSave: (List<NavDrawerPin>) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
var showDialog by remember { mutableStateOf(false) }
ClickPreference(
title = title,
summary = summary,
onClick = { showDialog = true },
interactionSource = interactionSource,
modifier = modifier,
)
if (showDialog) {
NavDrawerPreferenceDialog(
items = items,
onDismissRequest = { showDialog = false },
onClick = { index ->
val newItems =
items.toMutableList().apply {
set(index, items[index].let { it.copy(pinned = !it.pinned) })
}
onSave.invoke(newItems)
},
onMoveUp = { index ->
onSave(items.move(MoveDirection.UP, index))
},
onMoveDown = { index ->
onSave(items.move(MoveDirection.DOWN, index))
},
)
}
}
@Composable
fun NavDrawerPreferenceDialog(
items: List<NavDrawerPin>,
onDismissRequest: () -> Unit,
onClick: (Int) -> Unit,
onMoveUp: (Int) -> Unit,
onMoveDown: (Int) -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = stringResource(R.string.nav_drawer_pins),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 8.dp),
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
itemsIndexed(items, key = { _, item -> item.id }) { index, item ->
NavDrawerPreferenceListItem(
title = item.title,
pinned = item.pinned,
moveUpAllowed = index > 0,
moveDownAllowed = index < items.lastIndex,
onClick = { onClick.invoke(index) },
onMoveUp = { onMoveUp.invoke(index) },
onMoveDown = { onMoveDown.invoke(index) },
modifier = Modifier,
)
}
}
}
}
}
@Composable
fun NavDrawerPreferenceListItem(
title: String,
pinned: Boolean,
moveUpAllowed: Boolean,
moveDownAllowed: Boolean,
onClick: () -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 40.dp, max = 88.dp),
) {
ListItem(
selected = false,
headlineContent = {
Text(
text = title,
)
},
trailingContent = {
Switch(
checked = pinned,
onCheckedChange = {
onClick.invoke()
},
)
},
onClick = onClick,
modifier = Modifier.weight(1f),
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.wrapContentWidth(),
) {
MoveButton(R.string.fa_caret_up, moveUpAllowed, onMoveUp)
MoveButton(R.string.fa_caret_down, moveDownAllowed, onMoveDown)
}
}
}
}
@Composable
private fun MoveButton(
@StringRes icon: Int,
enabled: Boolean,
onClick: () -> Unit,
) = Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(32.dp),
) {
Text(
text = stringResource(icon),
fontSize = 16.sp,
fontFamily = FontAwesome,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
@PreviewTvSpec
@Composable
fun NavDrawerPreferenceListItemPreview() {
WholphinTheme {
NavDrawerPreferenceListItem(
title = "Movies",
pinned = true,
moveUpAllowed = true,
moveDownAllowed = true,
onClick = {},
onMoveUp = {},
onMoveDown = { },
modifier = Modifier.width(360.dp),
)
}
}

View file

@ -89,7 +89,7 @@ fun PreferencesContent(
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
var showPinFlow by remember { mutableStateOf(false) }
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
val navDrawerPins by viewModel.navDrawerPins.collectAsState(emptyList())
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) }
@ -335,21 +335,16 @@ fun PreferencesContent(
}
AppPreference.UserPinnedNavDrawerItems -> {
val selectedItems =
navDrawerPins.keys.mapNotNull {
if (navDrawerPins[it] ?: false) it else null
}
MultiChoicePreference(
NavDrawerPreference(
title = stringResource(pref.title),
summary = pref.summary(context, null),
possibleValues = navDrawerPins.keys,
selectedValues = selectedItems.toSet(),
onValueChange = { newSelectedItems ->
viewModel.updatePins(newSelectedItems)
items = navDrawerPins,
onSave = {
viewModel.updatePins(it)
},
) {
Text(it.name(context))
}
modifier = Modifier,
interactionSource = interactionSource,
)
}
AppPreference.SendAppLogs -> {

View file

@ -2,14 +2,12 @@ package com.github.damontecres.wholphin.ui.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.isPinned
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.NavPinType
@ -17,23 +15,28 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
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.SeerrServerRepository
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
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.StateFlow
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.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
@ -48,6 +51,7 @@ class PreferencesViewModel
private val rememberTabManager: RememberTabManager,
private val serverRepository: ServerRepository,
private val navDrawerItemRepository: NavDrawerItemRepository,
private val navDrawerService: NavDrawerService,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
private val deviceInfo: DeviceInfo,
@ -55,7 +59,46 @@ class PreferencesViewModel
) : ViewModel(),
RememberTabManager by rememberTabManager {
private lateinit var allNavDrawerItems: List<NavDrawerItem>
val navDrawerPins = MutableLiveData<Map<NavDrawerItem, Boolean>>(mapOf())
// val navDrawerPins = MutableLiveData<List<NavDrawerPin>>(emptyList())
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
@ -70,42 +113,50 @@ class PreferencesViewModel
init {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->
allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems()
val pins = serverPreferencesDao.getNavDrawerPinnedItems(user)
val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) }
this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins)
// fetchNavDrawerPins(user)
}
}
}
fun updatePins(newSelectedItems: List<NavDrawerItem>) {
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 ->
val disabledItems =
mutableListOf<NavDrawerItem>().apply {
addAll(allNavDrawerItems)
removeAll(newSelectedItems)
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")
}
val enabledItems = newSelectedItems.toSet()
val toSave =
disabledItems.map {
NavDrawerPinnedItem(
user.rowId,
it.id,
NavPinType.UNPINNED,
)
} +
enabledItems.map {
NavDrawerPinnedItem(
user.rowId,
it.id,
NavPinType.PINNED,
)
}
serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray())
val pins = serverPreferencesDao.getNavDrawerPinnedItems(user)
val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) }
this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins)
}
}
}
}