Add ability to customize the navigation drawer menu (#60)

Adds support for customizing the items listed on the left side
navigation drawer menu. Each item can be toggled on/off in settings.

The hidden ones are still accessible under "More" on the nav drawer,
which only appears if some items are hidden.

Additionally, if any of menu items are hidden, their "latest" content
will not appear on the home page.

The setting is stored per user profile.
This commit is contained in:
damontecres 2025-10-23 15:57:26 -04:00 committed by GitHub
parent 27d0a17a00
commit fc4954b561
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 776 additions and 184 deletions

View file

@ -0,0 +1,241 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "59351f2b9819f90fab82ff0f353c79a5",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_users_id_serverId",
"unique": true,
"columnNames": [
"id",
"serverId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
},
{
"name": "index_users_id",
"unique": false,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
},
{
"name": "index_users_serverId",
"unique": false,
"columnNames": [
"serverId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
}
],
"foreignKeys": [
{
"table": "servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "ItemPlayback",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT"
},
{
"fieldPath": "audioIndex",
"columnName": "audioIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subtitleIndex",
"columnName": "subtitleIndex",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_ItemPlayback_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "NavDrawerPinnedItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '59351f2b9819f90fab82ff0f353c79a5')"
]
}
}

View file

@ -8,20 +8,26 @@ import androidx.room.TypeConverters
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID import java.util.UUID
@Database( @Database(
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class], entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class],
version = 4, version = 5,
exportSchema = true, exportSchema = true,
autoMigrations = [AutoMigration(3, 4)], autoMigrations = [
AutoMigration(3, 4),
AutoMigration(4, 5),
],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun serverDao(): JellyfinServerDao abstract fun serverDao(): JellyfinServerDao
abstract fun itemPlaybackDao(): ItemPlaybackDao abstract fun itemPlaybackDao(): ItemPlaybackDao
abstract fun serverPreferencesDao(): ServerPreferencesDao
} }
class Converters { class Converters {

View file

@ -81,7 +81,7 @@ interface JellyfinServerDao {
fun updateUser(user: JellyfinUser): Int fun updateUser(user: JellyfinUser): Int
@Transaction @Transaction
fun addOrUpdateUser(user: JellyfinUser) { fun addOrUpdateUser(user: JellyfinUser): JellyfinUser {
val result = addUser(user) val result = addUser(user)
if (result == -1L) { if (result == -1L) {
val toSave = val toSave =
@ -92,7 +92,9 @@ interface JellyfinServerDao {
user user
} }
updateUser(toSave) updateUser(toSave)
return toSave
} }
return user.copy(rowId = result.toInt())
} }
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId") @Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")

View file

@ -0,0 +1,66 @@
package com.github.damontecres.wholphin.data
import android.content.Context
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.NavPinType
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 org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NavDrawerItemRepository
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val serverPreferencesDao: ServerPreferencesDao,
) {
suspend fun getNavDrawerItems(): List<NavDrawerItem> {
val user = serverRepository.currentUser
val userViews =
api.userViewsApi
.getUserViews(userId = user?.id)
.content.items
val builtins = listOf(NavDrawerItem.Favorites)
val libraries =
userViews
.filter { it.collectionType in supportedCollectionTypes }
.map {
ServerNavDrawerItem(
itemId = it.id,
name = it.name ?: it.id.toString(),
destination = BaseItem.from(it, api).destination(),
type = it.collectionType ?: CollectionType.UNKNOWN,
)
}
return builtins + libraries
}
suspend fun getFilteredNavDrawerItems(items: List<NavDrawerItem>): List<NavDrawerItem> {
val user = serverRepository.currentUser
val navDrawerPins =
user
?.let {
serverPreferencesDao.getNavDrawerPinnedItems(it)
}.orEmpty()
val filtered = items.filter { navDrawerPins.isPinned(it.id) }
if (items.size != filtered.size) {
// Some were filtered out, check if should include More
if (navDrawerPins.isPinned(NavDrawerItem.More.id)) {
return filtered + listOf(NavDrawerItem.More)
}
}
return filtered
}
}
fun List<NavDrawerPinnedItem>.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED

View file

@ -0,0 +1,18 @@
package com.github.damontecres.wholphin.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
@Dao
interface ServerPreferencesDao {
fun getNavDrawerPinnedItems(user: JellyfinUser): List<NavDrawerPinnedItem> = getNavDrawerPinnedItems(user.rowId)
@Query("SELECT * from NavDrawerPinnedItem WHERE userId=:userId")
fun getNavDrawerPinnedItems(userId: Int): List<NavDrawerPinnedItem>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveNavDrawerPinnedItems(vararg items: NavDrawerPinnedItem)
}

View file

@ -82,15 +82,13 @@ class ServerRepository
val sysInfo by apiClient.systemApi.getSystemInfo() val sysInfo by apiClient.systemApi.getSystemInfo()
val updatedServer = server.copy(name = sysInfo.serverName) val updatedServer = server.copy(name = sysInfo.serverName)
val updatedUser = var updatedUser =
user.copy( user.copy(
id = userDto.id, id = userDto.id,
name = userDto.name, name = userDto.name,
) )
if (updatedUser.rowId <= 0) {
}
serverDao.addOrUpdateServer(updatedServer) serverDao.addOrUpdateServer(updatedServer)
serverDao.addOrUpdateUser(updatedUser) updatedUser = serverDao.addOrUpdateUser(updatedUser)
userPreferencesDataStore.updateData { userPreferencesDataStore.updateData {
it it
.toBuilder() .toBuilder()

View file

@ -0,0 +1,28 @@
package com.github.damontecres.wholphin.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import com.github.damontecres.wholphin.data.JellyfinUser
enum class NavPinType {
PINNED,
UNPINNED,
}
@Entity(
foreignKeys = [
ForeignKey(
entity = JellyfinUser::class,
parentColumns = arrayOf("rowId"),
childColumns = arrayOf("userId"),
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
),
],
primaryKeys = ["userId", "itemId"],
)
data class NavDrawerPinnedItem(
val userId: Int,
val itemId: String,
val type: NavPinType,
)

View file

@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.AppDatabase
import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import dagger.Module import dagger.Module
@ -46,6 +47,10 @@ object DatabaseModule {
@Singleton @Singleton
fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao() fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao()
@Provides
@Singleton
fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao()
@Provides @Provides
@Singleton @Singleton
fun userPreferencesDataStore( fun userPreferencesDataStore(

View file

@ -526,6 +526,14 @@ sealed interface AppPreference<T> {
getter = { }, getter = { },
setter = { prefs, _ -> prefs }, setter = { prefs, _ -> prefs },
) )
val UserPinnedNavDrawerItems =
AppClickablePreference(
title = R.string.nav_drawer_pins,
summary = R.string.nav_drawer_pins_summary,
getter = { },
setter = { prefs, _ -> prefs },
)
} }
} }
@ -556,6 +564,13 @@ val basicPreferences =
AppPreference.SkipBackOnResume, AppPreference.SkipBackOnResume,
), ),
), ),
PreferenceGroup(
title = R.string.profile_specific_settings,
preferences =
listOf(
AppPreference.UserPinnedNavDrawerItems,
),
),
PreferenceGroup( PreferenceGroup(
title = R.string.about, title = R.string.about,
preferences = preferences =

View file

@ -3,10 +3,13 @@ package com.github.damontecres.wholphin.ui.main
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.supportItemKinds import com.github.damontecres.wholphin.util.supportItemKinds
@ -19,7 +22,6 @@ import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.UserDto
@ -36,6 +38,8 @@ class HomeViewModel
constructor( constructor(
val api: ApiClient, val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
val navDrawerItemRepository: NavDrawerItemRepository,
) : ViewModel() { ) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
val homeRows = MutableLiveData<List<HomeRow>>() val homeRows = MutableLiveData<List<HomeRow>>()
@ -52,64 +56,44 @@ class HomeViewModel
), ),
) { ) {
Timber.d("init HomeViewModel") Timber.d("init HomeViewModel")
val user by api.userApi.getCurrentUser()
// val displayPrefs =
// api.displayPreferencesApi
// .getDisplayPreferences(
// displayPreferencesId = "usersettings",
// client = "emby",
// ).content
val homeSections =
listOf(
HomeSection.RESUME,
HomeSection.NEXT_UP,
HomeSection.LATEST_MEDIA,
)
// TODO use display preferences?
// displayPrefs.customPrefs.entries serverRepository.currentUserDto?.let { userDto ->
// .filter { it.key.startsWith("homesection") && it.value.isNotNullOrBlank() } val includedIds =
// .sortedBy { it.key } navDrawerItemRepository
// .map { HomeSection.fromKey(it.value ?: "") } .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
// .filter { .filter { it is ServerNavDrawerItem }
// it in .map { (it as ServerNavDrawerItem).itemId }
// setOf( // TODO data is fetched all together which may be slow for large servers
// HomeSection.LATEST_MEDIA, val resume = getResume(userDto.id, limit)
// HomeSection.NEXT_UP, val nextUp = getNextUp(userDto.id, limit, prefs.enableRewatchingNextUp)
// HomeSection.RESUME, val latest = getLatest(userDto, limit, includedIds)
// )
// }
// TODO data is fetched all together which may be slow for large servers val homeRows =
val resume = getResume(user.id, limit) if (prefs.combineContinueNext) {
val nextUp = getNextUp(user.id, limit, prefs.enableRewatchingNextUp) listOf(
val latest = getLatest(user, limit) HomeRow(
section = HomeSection.NEXT_UP,
val homeRows = items = resume + nextUp,
if (prefs.combineContinueNext) { ),
listOf( *latest.toTypedArray(),
HomeRow( )
section = HomeSection.NEXT_UP, } else {
items = resume + nextUp, listOf(
), HomeRow(
*latest.toTypedArray(), section = HomeSection.RESUME,
) items = resume,
} else { ),
listOf( HomeRow(
HomeRow( section = HomeSection.NEXT_UP,
section = HomeSection.RESUME, items = nextUp,
items = resume, ),
), *latest.toTypedArray(),
HomeRow( )
section = HomeSection.NEXT_UP, }
items = nextUp, withContext(Dispatchers.Main) {
), this@HomeViewModel.homeRows.value = homeRows
*latest.toTypedArray(), loadingState.value = LoadingState.Success
)
} }
withContext(Dispatchers.Main) {
this@HomeViewModel.homeRows.value = homeRows
loadingState.value = LoadingState.Success
} }
} }
} }
@ -162,11 +146,16 @@ class HomeViewModel
private suspend fun getLatest( private suspend fun getLatest(
user: UserDto, user: UserDto,
limit: Int, limit: Int,
includedIds: List<UUID>,
): List<HomeRow> { ): List<HomeRow> {
val latestMediaIncludes = val latestMediaIncludes =
user.configuration?.orderedViews.orEmpty().toMutableList().apply { user.configuration
removeAll(user.configuration?.latestItemsExcludes.orEmpty()) ?.orderedViews
} .orEmpty()
.toMutableList()
.apply {
removeAll(user.configuration?.latestItemsExcludes.orEmpty())
}.filter { includedIds.contains(it) }
val views by api.userViewsApi.getUserViews() val views by api.userViewsApi.getUserViews()
val rows = val rows =

View file

@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.ui.nav
import android.content.Context import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@ -17,10 +16,13 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.KeyboardArrowLeft
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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -49,6 +51,7 @@ import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.NavigationDrawer import androidx.tv.material3.NavigationDrawer
import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.NavigationDrawerItemDefaults
import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.NavigationDrawerScope
import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
@ -57,26 +60,20 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.JellyfinServer import com.github.damontecres.wholphin.data.JellyfinServer
import com.github.damontecres.wholphin.data.JellyfinUser import com.github.damontecres.wholphin.data.JellyfinUser
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.ifElse 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.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.spacedByWithFooter
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -84,45 +81,25 @@ import javax.inject.Inject
class NavDrawerViewModel class NavDrawerViewModel
@Inject @Inject
constructor( constructor(
@param:ApplicationContext val context: Context, private val navDrawerItemRepository: NavDrawerItemRepository,
val serverRepository: ServerRepository,
val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
private var all: List<NavDrawerItem>? = null
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf()) val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
val selectedIndex = MutableLiveData<Int>(-1) val selectedIndex = MutableLiveData(-1)
val showMore = MutableLiveData(false)
fun init() {
viewModelScope.launchIO {
val all = all ?: navDrawerItemRepository.getNavDrawerItems()
this@NavDrawerViewModel.all = all
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
init {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler(true)) {
val userViews =
api.userViewsApi
.getUserViews()
.content.items
val libraries =
userViews
.filter { it.collectionType in supportedCollectionTypes }
.map {
NavDrawerItem(
id = it.id,
name = it.name ?: it.id.toString(),
destination = BaseItem.from(it, api).destination(),
type = it.collectionType ?: CollectionType.UNKNOWN,
iconStringRes = null,
)
}
val extra =
listOf(
NavDrawerItem(
id = UUID.randomUUID(),
name = context.getString(R.string.favorites),
destination = Destination.Favorites,
type = CollectionType.UNKNOWN,
iconStringRes = R.string.fa_heart,
),
)
Timber.d("Got ${userViews.size} user views filtered to ${libraries.size}")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@NavDrawerViewModel.libraries.value = extra + libraries this@NavDrawerViewModel.moreLibraries.value =
all.toMutableList().apply { removeAll(libraries) }
this@NavDrawerViewModel.libraries.value = libraries
} }
} }
} }
@ -130,15 +107,42 @@ class NavDrawerViewModel
fun setIndex(index: Int) { fun setIndex(index: Int) {
selectedIndex.value = index selectedIndex.value = index
} }
fun setShowMore(value: Boolean) {
showMore.value = value
}
} }
data class NavDrawerItem( sealed interface NavDrawerItem {
val id: UUID, val id: String
fun name(context: Context): String
object Favorites : NavDrawerItem {
override val id: String
get() = "a_favorites"
override fun name(context: Context): String = context.getString(R.string.favorites)
}
object More : NavDrawerItem {
override val id: String
get() = "a_more"
override fun name(context: Context): String = context.getString(R.string.more)
}
}
data class ServerNavDrawerItem(
val itemId: UUID,
val name: String, val name: String,
val destination: Destination, val destination: Destination,
val type: CollectionType, val type: CollectionType,
@param:StringRes val iconStringRes: Int?, ) : NavDrawerItem {
) override val id: String = "s_" + itemId.toServerString()
override fun name(context: Context): String = name
}
/** /**
* Display the left side navigation drawer with [DestinationContent] on the right * Display the left side navigation drawer with [DestinationContent] on the right
@ -162,17 +166,49 @@ fun NavDrawer(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val drawerFocusRequester = remember { FocusRequester() } val drawerFocusRequester = remember { FocusRequester() }
val focusRequester = remember { FocusRequester() }
// If the user presses back while on the home page, open the nav drawer, another back press will quit the app // If the user presses back while on the home page, open the nav drawer, another back press will quit the app
BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) { BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) {
drawerState.setValue(DrawerValue.Open) drawerState.setValue(DrawerValue.Open)
drawerFocusRequester.requestFocus() drawerFocusRequester.requestFocus()
} }
val moreLibraries by viewModel.moreLibraries.observeAsState(listOf())
val libraries by viewModel.libraries.observeAsState(listOf()) val libraries by viewModel.libraries.observeAsState(listOf())
LaunchedEffect(Unit) { viewModel.init() }
val showMore by viewModel.showMore.observeAsState(false)
// val libraries = if (showPinnedOnly) pinnedLibraries else allLibraries
// A negative index is a built in page, >=0 is a library // A negative index is a built in page, >=0 is a library
val selectedIndex by viewModel.selectedIndex.observeAsState(-1) val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
val focusRequester = remember { FocusRequester() }
fun setShowMore(value: Boolean) {
viewModel.setShowMore(value)
}
BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) {
setShowMore(false)
}
val onClick = { index: Int, item: NavDrawerItem ->
when (item) {
NavDrawerItem.Favorites -> {
viewModel.setIndex(index)
viewModel.navigationManager.navigateToFromDrawer(
Destination.Favorites,
)
}
NavDrawerItem.More -> {
setShowMore(!showMore)
}
is ServerNavDrawerItem -> {
viewModel.setIndex(index)
viewModel.navigationManager.navigateToFromDrawer(item.destination)
}
}
}
NavigationDrawer( NavigationDrawer(
modifier = modifier =
@ -205,6 +241,7 @@ fun NavDrawer(
onClick = { onClick = {
viewModel.navigationManager.navigateTo(Destination.UserList) viewModel.navigationManager.navigateTo(Destination.UserList)
}, },
modifier = Modifier.animateItem(),
) )
} }
item { item {
@ -217,10 +254,11 @@ fun NavDrawer(
viewModel.navigationManager.navigateToFromDrawer(Destination.Search) viewModel.navigationManager.navigateToFromDrawer(Destination.Search)
}, },
modifier = modifier =
Modifier.ifElse( Modifier
selectedIndex == -2, .ifElse(
Modifier.focusRequester(focusRequester), selectedIndex == -2,
), Modifier.focusRequester(focusRequester),
).animateItem(),
) )
} }
item { item {
@ -237,27 +275,44 @@ fun NavDrawer(
} }
}, },
modifier = modifier =
Modifier.ifElse( Modifier
selectedIndex == -1, .ifElse(
Modifier.focusRequester(focusRequester), selectedIndex == -1,
), Modifier.focusRequester(focusRequester),
).animateItem(),
) )
} }
itemsIndexed(libraries) { index, it -> itemsIndexed(libraries) { index, it ->
NavItem( NavItem(
library = it, library = it,
selected = selectedIndex == index, selected = selectedIndex == index,
onClick = { moreExpanded = showMore,
viewModel.setIndex(index) onClick = { onClick.invoke(index, it) },
viewModel.navigationManager.navigateToFromDrawer(it.destination)
},
modifier = modifier =
Modifier.ifElse( Modifier
selectedIndex == index, .ifElse(
Modifier.focusRequester(focusRequester), selectedIndex == index,
), Modifier.focusRequester(focusRequester),
).animateItem(),
) )
} }
if (showMore) {
itemsIndexed(moreLibraries) { index, it ->
NavItem(
library = it,
selected = selectedIndex == (index + libraries.size),
moreExpanded = showMore,
onClick = { onClick.invoke(index + libraries.size, it) },
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
modifier =
Modifier
.ifElse(
selectedIndex == (index + libraries.size),
Modifier.focusRequester(focusRequester),
).animateItem(),
)
}
}
item { item {
IconNavItem( IconNavItem(
text = "Settings", text = "Settings",
@ -270,7 +325,7 @@ fun NavDrawer(
), ),
) )
}, },
modifier = Modifier, modifier = Modifier.animateItem(),
) )
} }
} }
@ -339,20 +394,29 @@ fun NavigationDrawerScope.NavItem(
library: NavDrawerItem, library: NavDrawerItem,
onClick: () -> Unit, onClick: () -> Unit,
selected: Boolean, selected: Boolean,
moreExpanded: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
containerColor: Color = Color.Unspecified,
) { ) {
val context = LocalContext.current
val icon = val icon =
when (library.type) { when (library) {
CollectionType.MOVIES -> R.string.fa_film NavDrawerItem.Favorites -> R.string.fa_heart
CollectionType.TVSHOWS -> R.string.fa_tv NavDrawerItem.More -> R.string.fa_ellipsis
CollectionType.HOMEVIDEOS -> R.string.fa_video
CollectionType.LIVETV -> R.string.fa_tv is ServerNavDrawerItem ->
CollectionType.MUSIC -> R.string.fa_music when (library.type) {
CollectionType.BOXSETS -> R.string.fa_open_folder CollectionType.MOVIES -> R.string.fa_film
CollectionType.PLAYLISTS -> R.string.fa_list_ul CollectionType.TVSHOWS -> R.string.fa_tv
else -> library.iconStringRes CollectionType.HOMEVIDEOS -> R.string.fa_video
} ?: R.string.fa_film CollectionType.LIVETV -> R.string.fa_tv
CollectionType.MUSIC -> R.string.fa_music
CollectionType.BOXSETS -> R.string.fa_open_folder
CollectionType.PLAYLISTS -> R.string.fa_list_ul
else -> R.string.fa_film
}
}
val isFocused = interactionSource.collectIsFocusedAsState().value val isFocused = interactionSource.collectIsFocusedAsState().value
val color = val color =
when { when {
@ -364,6 +428,10 @@ fun NavigationDrawerScope.NavItem(
modifier = modifier, modifier = modifier,
selected = false, selected = false,
onClick = onClick, onClick = onClick,
colors =
NavigationDrawerItemDefaults.colors(
containerColor = containerColor,
),
leadingContent = { leadingContent = {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Text(
@ -375,11 +443,19 @@ fun NavigationDrawerScope.NavItem(
) )
} }
}, },
trailingContent = {
if (library is NavDrawerItem.More) {
Icon(
imageVector = if (moreExpanded) Icons.Default.ArrowDropDown else Icons.Default.KeyboardArrowLeft,
contentDescription = null,
)
}
},
interactionSource = null, interactionSource = null,
) { ) {
Text( Text(
modifier = Modifier, modifier = Modifier,
text = library.name ?: library.id.toString(), text = library.name(context),
maxLines = 1, maxLines = 1,
) )
} }

View file

@ -34,7 +34,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Switch
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
@ -53,6 +52,7 @@ import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.SortedSet
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@Composable @Composable
@ -197,7 +197,7 @@ fun <T> ComposablePreference(
} }
is AppMultiChoicePreference<*> -> { is AppMultiChoicePreference<*> -> {
val values = stringArrayResource(preference.displayValues).toList() val values = stringArrayResource(preference.displayValues).toSortedSet()
val summary = val summary =
preference.summary?.let { stringResource(it) } preference.summary?.let { stringResource(it) }
?: preference.summary(context, value) ?: preference.summary(context, value)
@ -207,46 +207,14 @@ fun <T> ComposablePreference(
list.addAll(value as List<Any>) list.addAll(value as List<Any>)
list list
} }
MultiChoicePreference(
val onClick = { item: Any -> possibleValues = values as SortedSet<Any>,
if (selectedValues.contains(item)) { selectedValues = selectedValues,
selectedValues.remove(item)
} else {
selectedValues.add(item)
}
onValueChange.invoke(selectedValues.toList() as T)
}
ClickPreference(
title = title, title = title,
summary = summary, summary = summary,
onClick = { onValueChange = {
dialogParams = onValueChange.invoke(selectedValues.toList() as T)
DialogParams(
title = title,
fromLongClick = false,
items =
values.mapIndexed { index, it ->
val item = preference.allValues[index]!!
DialogItem(
headlineContent = { Text(it) },
trailingContent = {
Switch(
checked = selectedValues.contains(item),
onCheckedChange = {
onClick.invoke(item)
},
)
},
onClick = {
onClick.invoke(item)
},
)
},
)
}, },
interactionSource = interactionSource,
modifier = modifier,
) )
} }

View file

@ -0,0 +1,93 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
@Composable
fun <T> MultiChoicePreference(
title: String,
summary: String?,
possibleValues: Set<T>,
selectedValues: Set<T>,
onValueChange: (List<T>) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
valueDisplay: @Composable (item: T) -> Unit = { Text(it.toString()) },
) {
// val values = stringArrayResource(preference.displayValues).toList()
// val summary =
// preference.summary?.let { stringResource(it) }
// ?: preference.summary(context, value)
val selectedValues =
remember {
val list = mutableStateSetOf<T>()
list.addAll(selectedValues)
list
}
val onClick = { item: T ->
if (selectedValues.contains(item)) {
selectedValues.remove(item)
} else {
selectedValues.add(item)
}
onValueChange.invoke(selectedValues.toList())
}
var dialogParams by remember { mutableStateOf<DialogParams?>(null) }
ClickPreference(
title = title,
summary = summary,
onClick = {
dialogParams =
DialogParams(
title = title,
fromLongClick = false,
items =
possibleValues.mapIndexed { index, item ->
DialogItem(
headlineContent = { valueDisplay.invoke(item) },
trailingContent = {
Switch(
checked = selectedValues.contains(item),
onCheckedChange = {
onClick.invoke(item)
},
)
},
onClick = {
onClick.invoke(item)
},
)
},
)
},
interactionSource = interactionSource,
modifier = modifier,
)
AnimatedVisibility(dialogParams != null) {
dialogParams?.let {
DialogPopup(
showDialog = true,
title = it.title,
dialogItems = it.items,
onDismissRequest = { dialogParams = null },
waitToLoad = false,
dismissOnClick = false,
)
}
}
}

View file

@ -69,6 +69,9 @@ fun PreferencesContent(
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
val state = rememberLazyListState() val state = rememberLazyListState()
var preferences by remember { mutableStateOf(initialPreferences) } var preferences by remember { mutableStateOf(initialPreferences) }
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect { viewModel.preferenceDataStore.data.collect {
preferences = it preferences = it
@ -262,6 +265,24 @@ fun PreferencesContent(
) )
} }
AppPreference.UserPinnedNavDrawerItems -> {
val selectedItems =
navDrawerPins.keys.mapNotNull {
if (navDrawerPins[it] ?: false) it else null
}
MultiChoicePreference(
title = stringResource(pref.title),
summary = pref.summary(context, null),
possibleValues = navDrawerPins.keys,
selectedValues = selectedItems.toSet(),
onValueChange = { newSelectedItems ->
viewModel.updatePins(newSelectedItems)
},
) {
Text(it.name(context))
}
}
else -> { else -> {
val value = pref.getter.invoke(preferences) val value = pref.getter.invoke(preferences)
ComposablePreference( ComposablePreference(

View file

@ -1,9 +1,21 @@
package com.github.damontecres.wholphin.ui.preferences package com.github.damontecres.wholphin.ui.preferences
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
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.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.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
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 javax.inject.Inject import javax.inject.Inject
@ -15,5 +27,54 @@ class PreferencesViewModel
val preferenceDataStore: DataStore<AppPreferences>, val preferenceDataStore: DataStore<AppPreferences>,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val rememberTabManager: RememberTabManager, val rememberTabManager: RememberTabManager,
val serverRepository: ServerRepository,
val navDrawerItemRepository: NavDrawerItemRepository,
val serverPreferencesDao: ServerPreferencesDao,
) : ViewModel(), ) : ViewModel(),
RememberTabManager by rememberTabManager RememberTabManager by rememberTabManager {
private lateinit var allNavDrawerItems: List<NavDrawerItem>
val navDrawerPins = MutableLiveData<Map<NavDrawerItem, Boolean>>(mapOf())
init {
viewModelScope.launchIO {
serverRepository.currentUser?.let { user ->
allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems()
val pins = serverPreferencesDao.getNavDrawerPinnedItems(user)
val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) }
this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins)
}
}
}
fun updatePins(newSelectedItems: List<NavDrawerItem>) {
viewModelScope.launchIO(ExceptionHandler(true)) {
serverRepository.currentUser?.let { user ->
val disabledItems =
mutableListOf<NavDrawerItem>().apply {
addAll(allNavDrawerItems)
removeAll(newSelectedItems)
}
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)
}
}
}
}

View file

@ -36,4 +36,6 @@
<string name="fa_list_ul" translatable="false">&#xf0ca;</string> <string name="fa_list_ul" translatable="false">&#xf0ca;</string>
<string name="fa_file_video" translatable="false">&#xf1c8;</string> <string name="fa_file_video" translatable="false">&#xf1c8;</string>
<string name="fa_volume_low" translatable="false">&#xf027;</string> <string name="fa_volume_low" translatable="false">&#xf027;</string>
<string name="fa_ellipsis" translatable="false">&#xf141;</string>
<string name="fa_ellipsis_vertical" translatable="false">&#xf142;</string>
</resources> </resources>

View file

@ -88,5 +88,8 @@
<string name="remove_favorite">Unfavorite</string> <string name="remove_favorite">Unfavorite</string>
<string name="add_favorite">Favorite</string> <string name="add_favorite">Favorite</string>
<string name="favorites">Favorites</string> <string name="favorites">Favorites</string>
<string name="nav_drawer_pins">Customize Navigation Drawer Items</string>
<string name="profile_specific_settings">User Profile Settings</string>
<string name="nav_drawer_pins_summary">Choose the default items to show, others will be hidden</string>
</resources> </resources>