Merge branch 'main' into fea/live-tv

This commit is contained in:
Damontecres 2025-10-23 15:57:38 -04:00
commit aab70b7366
No known key found for this signature in database
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 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.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
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.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@ -37,6 +39,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>>()
@ -53,64 +57,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
} }
} }
} }
@ -163,11 +147,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>