mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat: collections init
This commit is contained in:
parent
1bb3784431
commit
44bb940bc0
20 changed files with 2744 additions and 0 deletions
|
|
@ -11,6 +11,7 @@ import com.nuvio.app.core.auth.AuthStorage
|
|||
import com.nuvio.app.core.deeplink.handleAppUrl
|
||||
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
|
||||
import com.nuvio.app.features.addons.AddonStorage
|
||||
import com.nuvio.app.features.collection.CollectionStorage
|
||||
import com.nuvio.app.features.library.LibraryStorage
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsStorage
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
|
||||
|
|
@ -64,6 +65,7 @@ class MainActivity : ComponentActivity() {
|
|||
WatchProgressStorage.initialize(applicationContext)
|
||||
StreamLinkCacheStorage.initialize(applicationContext)
|
||||
PluginStorage.initialize(applicationContext)
|
||||
CollectionStorage.initialize(applicationContext)
|
||||
PlatformLocalAccountDataCleaner.initialize(applicationContext)
|
||||
EpisodeReleaseNotificationPlatform.initialize(applicationContext)
|
||||
EpisodeReleaseNotificationPlatform.bindActivity(this)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
actual object CollectionStorage {
|
||||
private const val preferencesName = "nuvio_collections"
|
||||
private const val payloadKey = "collections_payload"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
actual fun loadPayload(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(payloadKey), null)
|
||||
|
||||
actual fun savePayload(payload: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(payloadKey), payload)
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
@ -116,6 +116,12 @@ import com.nuvio.app.features.settings.AddonsSettingsScreen
|
|||
import com.nuvio.app.features.settings.PluginsSettingsScreen
|
||||
import com.nuvio.app.features.settings.AccountSettingsScreen
|
||||
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
||||
import com.nuvio.app.features.collection.CollectionManagementScreen
|
||||
import com.nuvio.app.features.collection.CollectionEditorScreen
|
||||
import com.nuvio.app.features.collection.CollectionEditorRepository
|
||||
import com.nuvio.app.features.collection.CollectionSyncService
|
||||
import com.nuvio.app.features.collection.FolderDetailScreen
|
||||
import com.nuvio.app.features.collection.FolderDetailRepository
|
||||
import com.nuvio.app.features.streams.StreamContext
|
||||
import com.nuvio.app.features.streams.StreamContextStore
|
||||
import com.nuvio.app.features.streams.StreamLinkCacheRepository
|
||||
|
|
@ -177,6 +183,15 @@ object PluginsSettingsRoute
|
|||
@Serializable
|
||||
object AccountSettingsRoute
|
||||
|
||||
@Serializable
|
||||
object CollectionsRoute
|
||||
|
||||
@Serializable
|
||||
data class CollectionEditorRoute(val collectionId: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class FolderDetailRoute(val collectionId: String, val folderId: String)
|
||||
|
||||
@Serializable
|
||||
data class StreamRoute(
|
||||
val type: String,
|
||||
|
|
@ -364,6 +379,9 @@ private fun MainAppContent(
|
|||
remember {
|
||||
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
||||
}
|
||||
remember {
|
||||
CollectionSyncService.startObserving()
|
||||
}
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
||||
|
|
@ -616,6 +634,10 @@ private fun MainAppContent(
|
|||
}
|
||||
},
|
||||
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
|
||||
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
|
||||
onFolderClick = { collectionId, folderId ->
|
||||
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
||||
},
|
||||
onInitialHomeContentRendered = { initialHomeReady = true },
|
||||
)
|
||||
|
||||
|
|
@ -1105,6 +1127,39 @@ private fun MainAppContent(
|
|||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<CollectionsRoute> {
|
||||
CollectionManagementScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onNavigateToEditor = { collectionId ->
|
||||
navController.navigate(CollectionEditorRoute(collectionId = collectionId))
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<CollectionEditorRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<CollectionEditorRoute>()
|
||||
CollectionEditorScreen(
|
||||
collectionId = route.collectionId,
|
||||
onBack = {
|
||||
CollectionEditorRepository.clear()
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<FolderDetailRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<FolderDetailRoute>()
|
||||
LaunchedEffect(route.collectionId, route.folderId) {
|
||||
FolderDetailRepository.initialize(route.collectionId, route.folderId)
|
||||
}
|
||||
FolderDetailScreen(
|
||||
onBack = {
|
||||
FolderDetailRepository.clear()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onPosterClick = { meta ->
|
||||
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NuvioPosterActionSheet(
|
||||
|
|
@ -1235,6 +1290,8 @@ private fun AppTabHost(
|
|||
onAddonsSettingsClick: () -> Unit = {},
|
||||
onPluginsSettingsClick: () -> Unit = {},
|
||||
onAccountSettingsClick: () -> Unit = {},
|
||||
onCollectionsSettingsClick: () -> Unit = {},
|
||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
||||
onInitialHomeContentRendered: () -> Unit = {},
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
|
|
@ -1248,6 +1305,7 @@ private fun AppTabHost(
|
|||
onPosterLongClick = onPosterLongClick,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
onContinueWatchingLongPress = onContinueWatchingLongPress,
|
||||
onFolderClick = onFolderClick,
|
||||
onFirstCatalogRendered = onInitialHomeContentRendered,
|
||||
)
|
||||
}
|
||||
|
|
@ -1281,6 +1339,7 @@ private fun AppTabHost(
|
|||
onAddonsClick = onAddonsSettingsClick,
|
||||
onPluginsClick = onPluginsSettingsClick,
|
||||
onAccountClick = onAccountSettingsClick,
|
||||
onCollectionsClick = onCollectionsSettingsClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.core.storage
|
|||
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.catalog.CatalogRepository
|
||||
import com.nuvio.app.features.collection.CollectionRepository
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
|
|
@ -40,6 +41,7 @@ internal object LocalAccountDataCleaner {
|
|||
WatchedRepository.clearLocalState()
|
||||
ContinueWatchingPreferencesRepository.clearLocalState()
|
||||
EpisodeReleaseNotificationsRepository.clearLocalState()
|
||||
CollectionRepository.clearLocalState()
|
||||
ThemeSettingsRepository.clearLocalState()
|
||||
TraktAuthRepository.clearLocalState()
|
||||
PlayerSettingsRepository.clearLocalState()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.nuvio.app.core.build.AppFeaturePolicy
|
|||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.collection.CollectionSyncService
|
||||
import com.nuvio.app.features.plugins.PluginRepository
|
||||
import com.nuvio.app.features.library.LibraryRepository
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
|
|
@ -56,6 +57,10 @@ object SyncManager {
|
|||
runCatching { ProfileSettingsSync.pull(profileId) }
|
||||
.onFailure { log.e(it) { "ProfileSettings pull failed" } }
|
||||
}
|
||||
launch {
|
||||
runCatching { CollectionSyncService.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Collections pull failed" } }
|
||||
}
|
||||
|
||||
log.i { "pullAllForProfile($profileId) — all pulls launched" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
data class CollectionEditorUiState(
|
||||
val isNew: Boolean = true,
|
||||
val collectionId: String = "",
|
||||
val title: String = "",
|
||||
val backdropImageUrl: String = "",
|
||||
val pinToTop: Boolean = false,
|
||||
val viewMode: FolderViewMode = FolderViewMode.TABBED_GRID,
|
||||
val showAllTab: Boolean = true,
|
||||
val folders: List<CollectionFolder> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
val availableCatalogs: List<AvailableCatalog> = emptyList(),
|
||||
val editingFolder: CollectionFolder? = null,
|
||||
val showFolderEditor: Boolean = false,
|
||||
val showCatalogPicker: Boolean = false,
|
||||
)
|
||||
|
||||
object CollectionEditorRepository {
|
||||
private val log = Logger.withTag("CollectionEditorRepository")
|
||||
|
||||
private val _uiState = MutableStateFlow(CollectionEditorUiState())
|
||||
val uiState: StateFlow<CollectionEditorUiState> = _uiState.asStateFlow()
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun initialize(collectionId: String?) {
|
||||
val catalogs = CollectionRepository.getAvailableCatalogs()
|
||||
|
||||
if (collectionId != null) {
|
||||
val existing = CollectionRepository.getCollection(collectionId)
|
||||
if (existing != null) {
|
||||
_uiState.value = CollectionEditorUiState(
|
||||
isNew = false,
|
||||
collectionId = existing.id,
|
||||
title = existing.title,
|
||||
backdropImageUrl = existing.backdropImageUrl.orEmpty(),
|
||||
pinToTop = existing.pinToTop,
|
||||
viewMode = existing.folderViewMode,
|
||||
showAllTab = existing.showAllTab,
|
||||
folders = existing.folders,
|
||||
isLoading = false,
|
||||
availableCatalogs = catalogs,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.value = CollectionEditorUiState(
|
||||
isNew = true,
|
||||
collectionId = Uuid.random().toString(),
|
||||
title = "",
|
||||
backdropImageUrl = "",
|
||||
pinToTop = false,
|
||||
viewMode = FolderViewMode.TABBED_GRID,
|
||||
showAllTab = true,
|
||||
folders = emptyList(),
|
||||
isLoading = false,
|
||||
availableCatalogs = catalogs,
|
||||
)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_uiState.value = CollectionEditorUiState()
|
||||
}
|
||||
|
||||
fun setTitle(title: String) {
|
||||
_uiState.value = _uiState.value.copy(title = title)
|
||||
}
|
||||
|
||||
fun setBackdropImageUrl(url: String) {
|
||||
_uiState.value = _uiState.value.copy(backdropImageUrl = url)
|
||||
}
|
||||
|
||||
fun setPinToTop(pinToTop: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(pinToTop = pinToTop)
|
||||
}
|
||||
|
||||
fun setViewMode(viewMode: FolderViewMode) {
|
||||
_uiState.value = _uiState.value.copy(viewMode = viewMode)
|
||||
}
|
||||
|
||||
fun setShowAllTab(show: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(showAllTab = show)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun addFolder() {
|
||||
val newFolder = CollectionFolder(
|
||||
id = Uuid.random().toString(),
|
||||
title = "New Folder",
|
||||
)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = newFolder,
|
||||
showFolderEditor = true,
|
||||
)
|
||||
}
|
||||
|
||||
fun editFolder(folderId: String) {
|
||||
val folder = _uiState.value.folders.find { it.id == folderId } ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder,
|
||||
showFolderEditor = true,
|
||||
)
|
||||
}
|
||||
|
||||
fun removeFolder(folderId: String) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
folders = _uiState.value.folders.filter { it.id != folderId },
|
||||
)
|
||||
}
|
||||
|
||||
fun moveFolderUp(index: Int) {
|
||||
val list = _uiState.value.folders.toMutableList()
|
||||
if (index <= 0 || index >= list.size) return
|
||||
val item = list.removeAt(index)
|
||||
list.add(index - 1, item)
|
||||
_uiState.value = _uiState.value.copy(folders = list)
|
||||
}
|
||||
|
||||
fun moveFolderDown(index: Int) {
|
||||
val list = _uiState.value.folders.toMutableList()
|
||||
if (index < 0 || index >= list.size - 1) return
|
||||
val item = list.removeAt(index)
|
||||
list.add(index + 1, item)
|
||||
_uiState.value = _uiState.value.copy(folders = list)
|
||||
}
|
||||
|
||||
fun updateFolderTitle(title: String) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
_uiState.value = _uiState.value.copy(editingFolder = folder.copy(title = title))
|
||||
}
|
||||
|
||||
fun updateFolderCoverImage(url: String) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(coverImageUrl = url, coverEmoji = null),
|
||||
)
|
||||
}
|
||||
|
||||
fun updateFolderCoverEmoji(emoji: String) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(coverEmoji = emoji, coverImageUrl = null),
|
||||
)
|
||||
}
|
||||
|
||||
fun clearFolderCover() {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(coverImageUrl = null, coverEmoji = null),
|
||||
)
|
||||
}
|
||||
|
||||
fun updateFolderTileShape(shape: PosterShape) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
val shapeStr = when (shape) {
|
||||
PosterShape.Poster -> "Poster"
|
||||
PosterShape.Landscape -> "Landscape"
|
||||
PosterShape.Square -> "Square"
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(tileShape = shapeStr),
|
||||
)
|
||||
}
|
||||
|
||||
fun updateFolderHideTitle(hide: Boolean) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(hideTitle = hide),
|
||||
)
|
||||
}
|
||||
|
||||
fun addCatalogSource(catalog: AvailableCatalog) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
val source = CollectionCatalogSource(
|
||||
addonId = catalog.addonId,
|
||||
type = catalog.type,
|
||||
catalogId = catalog.catalogId,
|
||||
)
|
||||
if (folder.catalogSources.any {
|
||||
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
|
||||
}) return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(catalogSources = folder.catalogSources + source),
|
||||
)
|
||||
}
|
||||
|
||||
fun removeCatalogSource(index: Int) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
if (index !in folder.catalogSources.indices) return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(
|
||||
catalogSources = folder.catalogSources.toMutableList().apply { removeAt(index) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleCatalogSource(catalog: AvailableCatalog) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
val existingIndex = folder.catalogSources.indexOfFirst {
|
||||
it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
|
||||
}
|
||||
if (existingIndex >= 0) {
|
||||
removeCatalogSource(existingIndex)
|
||||
} else {
|
||||
addCatalogSource(catalog)
|
||||
}
|
||||
}
|
||||
|
||||
fun showCatalogPicker() {
|
||||
_uiState.value = _uiState.value.copy(showCatalogPicker = true)
|
||||
}
|
||||
|
||||
fun hideCatalogPicker() {
|
||||
_uiState.value = _uiState.value.copy(showCatalogPicker = false)
|
||||
}
|
||||
|
||||
fun saveFolderEdit() {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
val existing = _uiState.value.folders
|
||||
val updated = if (existing.any { it.id == folder.id }) {
|
||||
existing.map { if (it.id == folder.id) folder else it }
|
||||
} else {
|
||||
existing + folder
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
folders = updated,
|
||||
editingFolder = null,
|
||||
showFolderEditor = false,
|
||||
showCatalogPicker = false,
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelFolderEdit() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = null,
|
||||
showFolderEditor = false,
|
||||
showCatalogPicker = false,
|
||||
)
|
||||
}
|
||||
|
||||
fun save(): Boolean {
|
||||
val state = _uiState.value
|
||||
if (state.title.isBlank()) return false
|
||||
|
||||
val collection = Collection(
|
||||
id = state.collectionId,
|
||||
title = state.title.trim(),
|
||||
backdropImageUrl = state.backdropImageUrl.ifBlank { null },
|
||||
pinToTop = state.pinToTop,
|
||||
viewMode = state.viewMode.name,
|
||||
showAllTab = state.showAllTab,
|
||||
folders = state.folders,
|
||||
)
|
||||
|
||||
if (state.isNew) {
|
||||
CollectionRepository.addCollection(collection)
|
||||
} else {
|
||||
CollectionRepository.updateCollection(collection)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,806 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.ArrowDownward
|
||||
import androidx.compose.material.icons.rounded.ArrowUpward
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CollectionEditorScreen(
|
||||
collectionId: String?,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val state by CollectionEditorRepository.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(collectionId) {
|
||||
CollectionEditorRepository.initialize(collectionId)
|
||||
}
|
||||
|
||||
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
if (state.showFolderEditor && state.editingFolder != null) {
|
||||
FolderEditorSheet(
|
||||
state = state,
|
||||
onDismiss = { CollectionEditorRepository.cancelFolderEdit() },
|
||||
)
|
||||
}
|
||||
|
||||
if (state.showCatalogPicker) {
|
||||
CatalogPickerSheet(
|
||||
availableCatalogs = state.availableCatalogs,
|
||||
selectedSources = state.editingFolder?.catalogSources.orEmpty(),
|
||||
onToggle = { CollectionEditorRepository.toggleCatalogSource(it) },
|
||||
onDismiss = { CollectionEditorRepository.hideCatalogPicker() },
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp,
|
||||
top = 10.dp + statusBarTop,
|
||||
end = 16.dp,
|
||||
bottom = 18.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (state.isNew) "New Collection" else "Edit Collection",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.title,
|
||||
onValueChange = { CollectionEditorRepository.setTitle(it) },
|
||||
label = { Text("Collection Title") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
// Backdrop URL
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.backdropImageUrl,
|
||||
onValueChange = { CollectionEditorRepository.setBackdropImageUrl(it) },
|
||||
label = { Text("Backdrop Image URL (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
// Pin to Top
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { CollectionEditorRepository.setPinToTop(!state.pinToTop) }
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(
|
||||
text = "Pin to Top",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = "Display this collection above regular catalog rows.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.pinToTop,
|
||||
onCheckedChange = { CollectionEditorRepository.setPinToTop(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// View Mode
|
||||
item {
|
||||
Column {
|
||||
Text(
|
||||
text = "View Mode",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FolderViewMode.entries.forEach { mode ->
|
||||
FilterChip(
|
||||
selected = state.viewMode == mode,
|
||||
onClick = { CollectionEditorRepository.setViewMode(mode) },
|
||||
label = {
|
||||
Text(
|
||||
when (mode) {
|
||||
FolderViewMode.TABBED_GRID -> "Tabbed Grid"
|
||||
FolderViewMode.ROWS -> "Rows"
|
||||
FolderViewMode.FOLLOW_LAYOUT -> "Follow Layout"
|
||||
}
|
||||
)
|
||||
},
|
||||
leadingIcon = if (state.viewMode == mode) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show All Tab
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { CollectionEditorRepository.setShowAllTab(!state.showAllTab) }
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(
|
||||
text = "Show \"All\" Tab",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = "Combine all folder catalogs into a single tab.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.showAllTab,
|
||||
onCheckedChange = { CollectionEditorRepository.setShowAllTab(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Folders Section Header
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "FOLDERS",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
TextButton(onClick = { CollectionEditorRepository.addFolder() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Add Folder")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Folder Items
|
||||
itemsIndexed(
|
||||
items = state.folders,
|
||||
key = { _, folder -> folder.id },
|
||||
) { index, folder ->
|
||||
FolderListItem(
|
||||
folder = folder,
|
||||
index = index,
|
||||
totalCount = state.folders.size,
|
||||
onEdit = { CollectionEditorRepository.editFolder(folder.id) },
|
||||
onDelete = { CollectionEditorRepository.removeFolder(folder.id) },
|
||||
onMoveUp = { CollectionEditorRepository.moveFolderUp(index) },
|
||||
onMoveDown = { CollectionEditorRepository.moveFolderDown(index) },
|
||||
)
|
||||
}
|
||||
|
||||
if (state.folders.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No folders yet. Add one to get started.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save button
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (CollectionEditorRepository.save()) {
|
||||
onBack()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
enabled = state.title.isNotBlank(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(if (state.isNew) "Create Collection" else "Save Changes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FolderListItem(
|
||||
folder: CollectionFolder,
|
||||
index: Int,
|
||||
totalCount: Int,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onMoveUp: () -> Unit,
|
||||
onMoveDown: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Folder cover preview
|
||||
if (folder.coverEmoji != null) {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = folder.coverEmoji, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = folder.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "${folder.catalogSources.size} source${if (folder.catalogSources.size != 1) "s" else ""} · ${folder.posterShape.name}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
IconButton(
|
||||
onClick = onMoveUp,
|
||||
enabled = index > 0,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ArrowUpward,
|
||||
contentDescription = "Move up",
|
||||
modifier = Modifier.size(16.dp).alpha(if (index > 0) 1f else 0.3f),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onMoveDown,
|
||||
enabled = index < totalCount - 1,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ArrowDownward,
|
||||
contentDescription = "Move down",
|
||||
modifier = Modifier.size(16.dp).alpha(if (index < totalCount - 1) 1f else 0.3f),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Edit,
|
||||
contentDescription = "Edit",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = "Delete",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun FolderEditorSheet(
|
||||
state: CollectionEditorUiState,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val folder = state.editingFolder ?: return
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Edit Folder",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = folder.title,
|
||||
onValueChange = { CollectionEditorRepository.updateFolderTitle(it) },
|
||||
label = { Text("Folder Title") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
// Cover (emoji or image url)
|
||||
item {
|
||||
Column {
|
||||
Text(
|
||||
text = "Cover",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = folder.coverEmoji == null && folder.coverImageUrl == null,
|
||||
onClick = { CollectionEditorRepository.clearFolderCover() },
|
||||
label = { Text("None") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = folder.coverEmoji != null,
|
||||
onClick = {
|
||||
if (folder.coverEmoji == null) {
|
||||
CollectionEditorRepository.updateFolderCoverEmoji("📁")
|
||||
}
|
||||
},
|
||||
label = { Text("Emoji") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = folder.coverImageUrl != null,
|
||||
onClick = {
|
||||
if (folder.coverImageUrl == null) {
|
||||
CollectionEditorRepository.updateFolderCoverImage("")
|
||||
}
|
||||
},
|
||||
label = { Text("Image") },
|
||||
)
|
||||
}
|
||||
if (folder.coverEmoji != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = folder.coverEmoji,
|
||||
onValueChange = { CollectionEditorRepository.updateFolderCoverEmoji(it) },
|
||||
label = { Text("Emoji") },
|
||||
modifier = Modifier.width(100.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
if (folder.coverImageUrl != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = folder.coverImageUrl,
|
||||
onValueChange = { CollectionEditorRepository.updateFolderCoverImage(it) },
|
||||
label = { Text("Image URL") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tile Shape
|
||||
item {
|
||||
Column {
|
||||
Text(
|
||||
text = "Tile Shape",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
PosterShape.entries.forEach { shape ->
|
||||
FilterChip(
|
||||
selected = folder.posterShape == shape,
|
||||
onClick = { CollectionEditorRepository.updateFolderTileShape(shape) },
|
||||
label = { Text(shape.name) },
|
||||
leadingIcon = if (folder.posterShape == shape) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide Title
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { CollectionEditorRepository.updateFolderHideTitle(!folder.hideTitle) }
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Hide Title",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Switch(
|
||||
checked = folder.hideTitle,
|
||||
onCheckedChange = { CollectionEditorRepository.updateFolderHideTitle(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Catalog Sources
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "CATALOG SOURCES",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Add")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(folder.catalogSources) { index, source ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "${source.catalogId} (${source.type})",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = source.addonId,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { CollectionEditorRepository.removeCatalogSource(index) },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Close,
|
||||
contentDescription = "Remove",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (folder.catalogSources.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No catalog sources. Tap \"Add\" to select from installed addons.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save / Cancel
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp, bottom = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { CollectionEditorRepository.cancelFolderEdit() },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(
|
||||
onClick = { CollectionEditorRepository.saveFolderEdit() },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = folder.title.isNotBlank(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text("Save Folder")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CatalogPickerSheet(
|
||||
availableCatalogs: List<AvailableCatalog>,
|
||||
selectedSources: List<CollectionCatalogSource>,
|
||||
onToggle: (AvailableCatalog) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Select Catalog Sources",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val grouped = availableCatalogs.groupBy { it.addonName }
|
||||
grouped.forEach { (addonName, catalogs) ->
|
||||
item {
|
||||
Text(
|
||||
text = addonName,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
catalogs.forEach { catalog ->
|
||||
val isSelected = selectedSources.any {
|
||||
it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
|
||||
}
|
||||
item(key = "${catalog.addonId}:${catalog.type}:${catalog.catalogId}") {
|
||||
val bgColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
}
|
||||
val borderColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bgColor)
|
||||
.border(1.dp, borderColor, RoundedCornerShape(10.dp))
|
||||
.clickable { onToggle(catalog) }
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = catalog.catalogName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = catalog.type.replaceFirstChar {
|
||||
if (it.isLowerCase()) it.titlecase() else it.toString()
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,430 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.ArrowDownward
|
||||
import androidx.compose.material.icons.rounded.ArrowUpward
|
||||
import androidx.compose.material.icons.rounded.ContentCopy
|
||||
import androidx.compose.material.icons.rounded.ContentPaste
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.Folder
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun CollectionManagementScreen(
|
||||
onBack: () -> Unit,
|
||||
onNavigateToEditor: (String?) -> Unit,
|
||||
) {
|
||||
val collections by CollectionRepository.collections.collectAsState()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var importText by remember { mutableStateOf("") }
|
||||
var importError by remember { mutableStateOf<String?>(null) }
|
||||
var showDeleteConfirm by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp,
|
||||
top = 10.dp + statusBarTop,
|
||||
end = 16.dp,
|
||||
bottom = 18.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Collections",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
val json = CollectionRepository.exportToJson()
|
||||
clipboardManager.setText(AnnotatedString(json))
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ContentCopy,
|
||||
contentDescription = "Copy JSON",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showImportDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ContentPaste,
|
||||
contentDescription = "Import",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "${collections.size} collection${if (collections.size != 1) "s" else ""}, " +
|
||||
"${collections.sumOf { it.folders.size }} folder${if (collections.sumOf { it.folders.size } != 1) "s" else ""}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Button(
|
||||
onClick = { onNavigateToEditor(null) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("New Collection")
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items = collections,
|
||||
key = { _, collection -> collection.id },
|
||||
) { index, collection ->
|
||||
CollectionListItem(
|
||||
collection = collection,
|
||||
index = index,
|
||||
totalCount = collections.size,
|
||||
onEdit = { onNavigateToEditor(collection.id) },
|
||||
onDelete = { showDeleteConfirm = collection.id },
|
||||
onMoveUp = { CollectionRepository.moveUp(index) },
|
||||
onMoveDown = { CollectionRepository.moveDown(index) },
|
||||
)
|
||||
}
|
||||
|
||||
if (collections.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 48.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Folder,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "No collections yet",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Create one to organize your catalogs.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showImportDialog) {
|
||||
ImportDialog(
|
||||
importText = importText,
|
||||
importError = importError,
|
||||
onTextChange = {
|
||||
importText = it
|
||||
importError = null
|
||||
},
|
||||
onConfirm = {
|
||||
val result = CollectionRepository.validateJson(importText)
|
||||
if (result.valid) {
|
||||
CollectionRepository.importFromJson(importText)
|
||||
showImportDialog = false
|
||||
importText = ""
|
||||
importError = null
|
||||
} else {
|
||||
importError = result.error
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
showImportDialog = false
|
||||
importText = ""
|
||||
importError = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteConfirm != null) {
|
||||
val collectionId = showDeleteConfirm!!
|
||||
val collection = collections.find { it.id == collectionId }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirm = null },
|
||||
title = { Text("Delete Collection") },
|
||||
text = {
|
||||
Text("Delete \"${collection?.title ?: ""}\"? This cannot be undone.")
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
CollectionRepository.removeCollection(collectionId)
|
||||
showDeleteConfirm = null
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirm = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CollectionListItem(
|
||||
collection: Collection,
|
||||
index: Int,
|
||||
totalCount: Int,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onMoveUp: () -> Unit,
|
||||
onMoveDown: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = collection.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}" +
|
||||
if (collection.pinToTop) " · Pinned" else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onMoveUp,
|
||||
enabled = index > 0,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ArrowUpward,
|
||||
contentDescription = "Move up",
|
||||
modifier = Modifier.size(18.dp).alpha(if (index > 0) 1f else 0.3f),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onMoveDown,
|
||||
enabled = index < totalCount - 1,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ArrowDownward,
|
||||
contentDescription = "Move down",
|
||||
modifier = Modifier.size(18.dp).alpha(if (index < totalCount - 1) 1f else 0.3f),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(
|
||||
onClick = onEdit,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Edit,
|
||||
contentDescription = "Edit",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = "Delete",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportDialog(
|
||||
importText: String,
|
||||
importError: String?,
|
||||
onTextChange: (String) -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Import Collections") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = "Paste your collections JSON below.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = importText,
|
||||
onValueChange = onTextChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp),
|
||||
label = { Text("JSON") },
|
||||
isError = importError != null,
|
||||
supportingText = importError?.let {
|
||||
{ Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { onConfirm() }),
|
||||
maxLines = 10,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
enabled = importText.isNotBlank(),
|
||||
) {
|
||||
Text("Import")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
enum class FolderViewMode {
|
||||
TABBED_GRID,
|
||||
ROWS,
|
||||
FOLLOW_LAYOUT;
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): FolderViewMode =
|
||||
entries.firstOrNull { it.name.equals(value, ignoreCase = true) } ?: TABBED_GRID
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class CollectionCatalogSource(
|
||||
val addonId: String,
|
||||
val type: String,
|
||||
val catalogId: String,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class CollectionFolder(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val coverImageUrl: String? = null,
|
||||
val coverEmoji: String? = null,
|
||||
val tileShape: String = "Poster",
|
||||
val hideTitle: Boolean = false,
|
||||
val catalogSources: List<CollectionCatalogSource> = emptyList(),
|
||||
) {
|
||||
val posterShape: PosterShape
|
||||
get() = when (tileShape.lowercase()) {
|
||||
"poster" -> PosterShape.Poster
|
||||
"landscape", "wide" -> PosterShape.Landscape
|
||||
"square" -> PosterShape.Square
|
||||
else -> PosterShape.Poster
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class Collection(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val backdropImageUrl: String? = null,
|
||||
val pinToTop: Boolean = false,
|
||||
val viewMode: String = "TABBED_GRID",
|
||||
val showAllTab: Boolean = true,
|
||||
val folders: List<CollectionFolder> = emptyList(),
|
||||
) {
|
||||
val folderViewMode: FolderViewMode
|
||||
get() = FolderViewMode.fromString(viewMode)
|
||||
}
|
||||
|
||||
data class AvailableCatalog(
|
||||
val addonId: String,
|
||||
val addonName: String,
|
||||
val type: String,
|
||||
val catalogId: String,
|
||||
val catalogName: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SupabaseCollectionBlob(
|
||||
@SerialName("profile_id") val profileId: Int = 1,
|
||||
@SerialName("collections_json") val collectionsJson: kotlinx.serialization.json.JsonElement = kotlinx.serialization.json.JsonArray(emptyList()),
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
|
||||
data class ValidationResult(
|
||||
val valid: Boolean,
|
||||
val error: String? = null,
|
||||
val collectionCount: Int = 0,
|
||||
val folderCount: Int = 0,
|
||||
)
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object CollectionRepository {
|
||||
private val log = Logger.withTag("CollectionRepository")
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val _collections = MutableStateFlow<List<Collection>>(emptyList())
|
||||
val collections: StateFlow<List<Collection>> = _collections.asStateFlow()
|
||||
|
||||
private var hasLoaded = false
|
||||
|
||||
fun initialize() {
|
||||
if (hasLoaded) return
|
||||
hasLoaded = true
|
||||
val payload = CollectionStorage.loadPayload()
|
||||
if (payload.isNullOrBlank()) return
|
||||
|
||||
runCatching {
|
||||
_collections.value = json.decodeFromString<List<Collection>>(payload)
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Failed to load collections from storage" }
|
||||
}
|
||||
}
|
||||
|
||||
fun onProfileChanged() {
|
||||
hasLoaded = false
|
||||
_collections.value = emptyList()
|
||||
}
|
||||
|
||||
fun clearLocalState() {
|
||||
hasLoaded = false
|
||||
_collections.value = emptyList()
|
||||
}
|
||||
|
||||
fun getCollection(id: String): Collection? =
|
||||
_collections.value.find { it.id == id }
|
||||
|
||||
fun addCollection(collection: Collection) {
|
||||
ensureLoaded()
|
||||
_collections.value = _collections.value + collection
|
||||
persist()
|
||||
}
|
||||
|
||||
fun updateCollection(collection: Collection) {
|
||||
ensureLoaded()
|
||||
_collections.value = _collections.value.map {
|
||||
if (it.id == collection.id) collection else it
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
fun removeCollection(collectionId: String) {
|
||||
ensureLoaded()
|
||||
_collections.value = _collections.value.filter { it.id != collectionId }
|
||||
persist()
|
||||
}
|
||||
|
||||
fun setCollections(collections: List<Collection>) {
|
||||
_collections.value = collections
|
||||
persist()
|
||||
}
|
||||
|
||||
fun moveUp(index: Int) {
|
||||
ensureLoaded()
|
||||
val list = _collections.value.toMutableList()
|
||||
if (index <= 0 || index >= list.size) return
|
||||
val item = list.removeAt(index)
|
||||
list.add(index - 1, item)
|
||||
_collections.value = list
|
||||
persist()
|
||||
}
|
||||
|
||||
fun moveDown(index: Int) {
|
||||
ensureLoaded()
|
||||
val list = _collections.value.toMutableList()
|
||||
if (index < 0 || index >= list.size - 1) return
|
||||
val item = list.removeAt(index)
|
||||
list.add(index + 1, item)
|
||||
_collections.value = list
|
||||
persist()
|
||||
}
|
||||
|
||||
fun exportToJson(): String {
|
||||
ensureLoaded()
|
||||
return json.encodeToString(_collections.value)
|
||||
}
|
||||
|
||||
fun importFromJson(jsonString: String): Result<List<Collection>> {
|
||||
return runCatching {
|
||||
val imported = json.decodeFromString<List<Collection>>(jsonString)
|
||||
_collections.value = imported
|
||||
persist()
|
||||
imported
|
||||
}
|
||||
}
|
||||
|
||||
fun validateJson(jsonString: String): ValidationResult {
|
||||
if (jsonString.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "JSON is empty.")
|
||||
}
|
||||
return try {
|
||||
val collections = json.decodeFromString<List<Collection>>(jsonString)
|
||||
var totalFolders = 0
|
||||
collections.forEachIndexed { ci, c ->
|
||||
if (c.id.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "Collection ${ci + 1} has blank id.")
|
||||
}
|
||||
if (c.title.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "Collection '${c.id}' has blank title.")
|
||||
}
|
||||
c.folders.forEachIndexed { fi, f ->
|
||||
if (f.id.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "Folder ${fi + 1} in '${c.title}' has blank id.")
|
||||
}
|
||||
if (f.title.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "Folder '${f.id}' in '${c.title}' has blank title.")
|
||||
}
|
||||
f.catalogSources.forEachIndexed { si, s ->
|
||||
if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) {
|
||||
return ValidationResult(valid = false, error = "Source ${si + 1} in folder '${f.title}' has blank fields.")
|
||||
}
|
||||
}
|
||||
totalFolders++
|
||||
}
|
||||
}
|
||||
ValidationResult(
|
||||
valid = true,
|
||||
collectionCount = collections.size,
|
||||
folderCount = totalFolders,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ValidationResult(valid = false, error = "Invalid JSON: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun generateId(): String = Uuid.random().toString()
|
||||
|
||||
fun getAvailableCatalogs(): List<AvailableCatalog> {
|
||||
val addons = AddonRepository.uiState.value.addons
|
||||
return addons.mapNotNull { addon ->
|
||||
val manifest = addon.manifest ?: return@mapNotNull null
|
||||
addon to manifest
|
||||
}.flatMap { (addon, manifest) ->
|
||||
manifest.catalogs
|
||||
.filter { catalog -> catalog.extra.none { it.isRequired } }
|
||||
.map { catalog ->
|
||||
AvailableCatalog(
|
||||
addonId = manifest.id,
|
||||
addonName = manifest.name,
|
||||
type = catalog.type,
|
||||
catalogId = catalog.id,
|
||||
catalogName = catalog.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun applyFromRemote(collections: List<Collection>) {
|
||||
_collections.value = collections
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun ensureLoaded() {
|
||||
if (!hasLoaded) initialize()
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
runCatching {
|
||||
CollectionStorage.savePayload(json.encodeToString(_collections.value))
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Failed to persist collections" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
internal expect object CollectionStorage {
|
||||
fun loadPayload(): String?
|
||||
fun savePayload(payload: String)
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import io.github.jan.supabase.postgrest.postgrest
|
||||
import io.github.jan.supabase.postgrest.rpc
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
object CollectionSyncService {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val log = Logger.withTag("CollectionSyncService")
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private const val PUSH_DEBOUNCE_MS = 1500L
|
||||
|
||||
@Volatile
|
||||
var isSyncingFromRemote: Boolean = false
|
||||
|
||||
private var pushJob: Job? = null
|
||||
private var observeJob: Job? = null
|
||||
|
||||
fun startObserving() {
|
||||
if (observeJob?.isActive == true) return
|
||||
observeLocalChangesAndPush()
|
||||
}
|
||||
|
||||
suspend fun pullFromServer(profileId: Int) {
|
||||
runCatching {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_collections", params)
|
||||
val blobs = result.decodeList<SupabaseCollectionBlob>()
|
||||
val blob = blobs.firstOrNull()
|
||||
|
||||
if (blob == null) {
|
||||
log.i { "pullFromServer — no remote collections found" }
|
||||
return
|
||||
}
|
||||
|
||||
val remoteJson = blob.collectionsJson.toString()
|
||||
val localJson = CollectionRepository.exportToJson()
|
||||
|
||||
if (remoteJson == "[]" || remoteJson == "null") {
|
||||
val currentCollections = CollectionRepository.collections.value
|
||||
if (currentCollections.isNotEmpty()) {
|
||||
log.i { "pullFromServer — remote empty, preserving local ${currentCollections.size} collections" }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteJson == localJson) {
|
||||
log.d { "pullFromServer — remote matches local, no update needed" }
|
||||
return
|
||||
}
|
||||
|
||||
val remoteCollections = runCatching {
|
||||
json.decodeFromString<List<Collection>>(remoteJson)
|
||||
}.getOrNull()
|
||||
|
||||
if (remoteCollections != null) {
|
||||
isSyncingFromRemote = true
|
||||
CollectionRepository.applyFromRemote(remoteCollections)
|
||||
isSyncingFromRemote = false
|
||||
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
|
||||
} else {
|
||||
log.w { "pullFromServer — failed to parse remote collections JSON" }
|
||||
}
|
||||
}.onFailure { e ->
|
||||
isSyncingFromRemote = false
|
||||
log.e(e) { "pullFromServer — FAILED" }
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerPush() {
|
||||
pushJob?.cancel()
|
||||
pushJob = scope.launch {
|
||||
delay(500)
|
||||
if (isSyncingFromRemote) return@launch
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@launch
|
||||
pushToRemote()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushToRemote() {
|
||||
runCatching {
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
val collectionsJson = CollectionRepository.exportToJson()
|
||||
val jsonElement = runCatching {
|
||||
json.parseToJsonElement(collectionsJson)
|
||||
}.getOrDefault(JsonArray(emptyList()))
|
||||
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_collections_json", jsonElement)
|
||||
}
|
||||
SupabaseProvider.client.postgrest.rpc("sync_push_collections", params)
|
||||
log.d { "pushToRemote — success" }
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "pushToRemote — FAILED" }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun observeLocalChangesAndPush() {
|
||||
observeJob = scope.launch {
|
||||
CollectionRepository.collections
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.debounce(PUSH_DEBOUNCE_MS)
|
||||
.collect {
|
||||
if (isSyncingFromRemote) return@collect
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@collect
|
||||
pushToRemote()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||
import com.nuvio.app.features.home.HomeCatalogSection
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class FolderTab(
|
||||
val label: String,
|
||||
val typeLabel: String = "",
|
||||
val items: List<MetaPreview> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val isAllTab: Boolean = false,
|
||||
)
|
||||
|
||||
data class FolderDetailUiState(
|
||||
val folder: CollectionFolder? = null,
|
||||
val collectionTitle: String = "",
|
||||
val viewMode: FolderViewMode = FolderViewMode.TABBED_GRID,
|
||||
val tabs: List<FolderTab> = emptyList(),
|
||||
val selectedTabIndex: Int = 0,
|
||||
val isLoading: Boolean = true,
|
||||
val showAllTab: Boolean = true,
|
||||
)
|
||||
|
||||
object FolderDetailRepository {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val log = Logger.withTag("FolderDetailRepository")
|
||||
|
||||
private val _uiState = MutableStateFlow(FolderDetailUiState())
|
||||
val uiState: StateFlow<FolderDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadJobs = mutableListOf<Job>()
|
||||
|
||||
fun initialize(collectionId: String, folderId: String) {
|
||||
clear()
|
||||
|
||||
val collection = CollectionRepository.getCollection(collectionId)
|
||||
if (collection == null) {
|
||||
_uiState.value = FolderDetailUiState(isLoading = false)
|
||||
return
|
||||
}
|
||||
|
||||
val folder = collection.folders.find { it.id == folderId }
|
||||
if (folder == null) {
|
||||
_uiState.value = FolderDetailUiState(isLoading = false)
|
||||
return
|
||||
}
|
||||
|
||||
val showAll = collection.showAllTab && folder.catalogSources.size > 1
|
||||
val addons = AddonRepository.uiState.value.addons
|
||||
|
||||
val tabs = buildList {
|
||||
if (showAll) {
|
||||
add(FolderTab(label = "All", isAllTab = true, isLoading = true))
|
||||
}
|
||||
folder.catalogSources.forEach { source ->
|
||||
val addon = addons.find { it.manifest?.id == source.addonId }
|
||||
val catalog = addon?.manifest?.catalogs?.find {
|
||||
it.id == source.catalogId && it.type == source.type
|
||||
}
|
||||
val label = catalog?.name ?: source.catalogId
|
||||
val typeLabel = source.type.replaceFirstChar {
|
||||
if (it.isLowerCase()) it.titlecase() else it.toString()
|
||||
}
|
||||
add(
|
||||
FolderTab(
|
||||
label = "$label ($typeLabel)",
|
||||
typeLabel = typeLabel,
|
||||
isLoading = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.value = FolderDetailUiState(
|
||||
folder = folder,
|
||||
collectionTitle = collection.title,
|
||||
viewMode = collection.folderViewMode,
|
||||
tabs = tabs,
|
||||
selectedTabIndex = 0,
|
||||
isLoading = true,
|
||||
showAllTab = showAll,
|
||||
)
|
||||
|
||||
// Load catalog data for each source
|
||||
folder.catalogSources.forEachIndexed { sourceIndex, source ->
|
||||
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
|
||||
val addon = addons.find { it.manifest?.id == source.addonId }
|
||||
if (addon == null) {
|
||||
updateTab(tabIndex) { it.copy(isLoading = false, error = "Addon not found: ${source.addonId}") }
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
val job = scope.launch {
|
||||
runCatching {
|
||||
val page = fetchCatalogPage(
|
||||
manifestUrl = addon.manifestUrl,
|
||||
type = source.type,
|
||||
catalogId = source.catalogId,
|
||||
)
|
||||
updateTab(tabIndex) { it.copy(items = page.items, isLoading = false) }
|
||||
rebuildAllTab()
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Failed to load catalog ${source.catalogId} from ${source.addonId}" }
|
||||
updateTab(tabIndex) { it.copy(isLoading = false, error = e.message) }
|
||||
rebuildAllTab()
|
||||
}
|
||||
}
|
||||
loadJobs.add(job)
|
||||
}
|
||||
|
||||
// If no sources, mark as done
|
||||
if (folder.catalogSources.isEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectTab(index: Int) {
|
||||
_uiState.value = _uiState.value.copy(selectedTabIndex = index)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
loadJobs.forEach { it.cancel() }
|
||||
loadJobs.clear()
|
||||
_uiState.value = FolderDetailUiState()
|
||||
}
|
||||
|
||||
private fun updateTab(index: Int, transform: (FolderTab) -> FolderTab) {
|
||||
val current = _uiState.value
|
||||
val updatedTabs = current.tabs.toMutableList()
|
||||
if (index !in updatedTabs.indices) return
|
||||
updatedTabs[index] = transform(updatedTabs[index])
|
||||
|
||||
val allDone = updatedTabs.none { !it.isAllTab && it.isLoading }
|
||||
_uiState.value = current.copy(
|
||||
tabs = updatedTabs,
|
||||
isLoading = !allDone,
|
||||
)
|
||||
}
|
||||
|
||||
private fun rebuildAllTab() {
|
||||
val current = _uiState.value
|
||||
if (!current.showAllTab) return
|
||||
val sourceTabs = current.tabs.filter { !it.isAllTab }
|
||||
if (sourceTabs.any { it.isLoading }) return
|
||||
|
||||
// Round-robin merge
|
||||
val merged = mutableListOf<MetaPreview>()
|
||||
val iterators = sourceTabs.map { it.items.iterator() }
|
||||
var hasMore = true
|
||||
while (hasMore) {
|
||||
hasMore = false
|
||||
for (iterator in iterators) {
|
||||
if (iterator.hasNext()) {
|
||||
merged.add(iterator.next())
|
||||
hasMore = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val updatedTabs = current.tabs.toMutableList()
|
||||
val allTabIndex = updatedTabs.indexOfFirst { it.isAllTab }
|
||||
if (allTabIndex >= 0) {
|
||||
updatedTabs[allTabIndex] = updatedTabs[allTabIndex].copy(
|
||||
items = merged,
|
||||
isLoading = false,
|
||||
)
|
||||
}
|
||||
_uiState.value = current.copy(tabs = updatedTabs)
|
||||
}
|
||||
|
||||
fun getCatalogSectionsForRows(): List<HomeCatalogSection> {
|
||||
val current = _uiState.value
|
||||
val folder = current.folder ?: return emptyList()
|
||||
|
||||
return current.tabs.filter { !it.isAllTab && it.items.isNotEmpty() }.map { tab ->
|
||||
HomeCatalogSection(
|
||||
key = "folder_${folder.id}_${tab.label}",
|
||||
title = tab.label,
|
||||
subtitle = tab.typeLabel,
|
||||
addonName = "",
|
||||
type = "",
|
||||
manifestUrl = "",
|
||||
catalogId = "",
|
||||
items = tab.items,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioPosterCard
|
||||
import com.nuvio.app.core.ui.NuvioPosterShape
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
|
||||
import com.nuvio.app.core.ui.nuvioPlatformExtraTopPadding
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
||||
|
||||
@Composable
|
||||
fun FolderDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onPosterClick: (MetaPreview) -> Unit,
|
||||
) {
|
||||
val uiState by FolderDetailRepository.uiState.collectAsState()
|
||||
val folder = uiState.folder
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
NuvioScreenHeader(
|
||||
title = folder?.title ?: uiState.collectionTitle,
|
||||
onBack = onBack,
|
||||
)
|
||||
|
||||
if (folder == null && !uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Folder not found",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
when (uiState.viewMode) {
|
||||
FolderViewMode.TABBED_GRID -> TabbedGridContent(
|
||||
uiState = uiState,
|
||||
onTabSelected = { FolderDetailRepository.selectTab(it) },
|
||||
onPosterClick = onPosterClick,
|
||||
)
|
||||
FolderViewMode.ROWS -> RowsContent(
|
||||
uiState = uiState,
|
||||
onPosterClick = onPosterClick,
|
||||
)
|
||||
FolderViewMode.FOLLOW_LAYOUT -> RowsContent(
|
||||
uiState = uiState,
|
||||
onPosterClick = onPosterClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabbedGridContent(
|
||||
uiState: FolderDetailUiState,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
onPosterClick: (MetaPreview) -> Unit,
|
||||
) {
|
||||
val folder = uiState.folder ?: return
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Folder header with cover + tabs
|
||||
FolderHeader(folder = folder)
|
||||
|
||||
if (uiState.tabs.size > 1) {
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = uiState.selectedTabIndex,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
edgePadding = 16.dp,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
divider = {},
|
||||
) {
|
||||
uiState.tabs.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
selected = index == uiState.selectedTabIndex,
|
||||
onClick = { onTabSelected(index) },
|
||||
text = {
|
||||
Text(
|
||||
text = tab.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Grid content for selected tab
|
||||
val selectedTab = uiState.tabs.getOrNull(uiState.selectedTabIndex)
|
||||
if (selectedTab == null) return
|
||||
|
||||
when {
|
||||
selectedTab.isLoading -> LoadingIndicator()
|
||||
selectedTab.error != null -> ErrorMessage(selectedTab.error)
|
||||
selectedTab.items.isEmpty() -> EmptyMessage()
|
||||
else -> {
|
||||
val posterShape = folder.posterShape
|
||||
val nuvioShape = posterShape.toNuvioPosterShape()
|
||||
val columns = when (nuvioShape) {
|
||||
NuvioPosterShape.Poster -> 3
|
||||
NuvioPosterShape.Square -> 3
|
||||
NuvioPosterShape.Landscape -> 2
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 18.dp + nuvioPlatformExtraBottomPadding,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
items(
|
||||
items = selectedTab.items,
|
||||
key = { it.id },
|
||||
) { item ->
|
||||
NuvioPosterCard(
|
||||
title = item.name,
|
||||
imageUrl = item.poster,
|
||||
shape = nuvioShape,
|
||||
detailLine = item.releaseInfo,
|
||||
onClick = { onPosterClick(item) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowsContent(
|
||||
uiState: FolderDetailUiState,
|
||||
onPosterClick: (MetaPreview) -> Unit,
|
||||
) {
|
||||
val sections = FolderDetailRepository.getCatalogSectionsForRows()
|
||||
|
||||
if (uiState.isLoading && sections.isEmpty()) {
|
||||
LoadingIndicator()
|
||||
return
|
||||
}
|
||||
|
||||
if (sections.isEmpty() && !uiState.isLoading) {
|
||||
EmptyMessage()
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
bottom = 18.dp + nuvioPlatformExtraBottomPadding,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(
|
||||
items = sections,
|
||||
key = { it.key },
|
||||
) { section ->
|
||||
HomeCatalogRowSection(
|
||||
section = section,
|
||||
entries = section.items.take(18),
|
||||
onPosterClick = { onPosterClick(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FolderHeader(folder: CollectionFolder) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Cover image or emoji
|
||||
when {
|
||||
!folder.coverImageUrl.isNullOrBlank() -> {
|
||||
AsyncImage(
|
||||
model = folder.coverImageUrl,
|
||||
contentDescription = folder.title,
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
!folder.coverEmoji.isNullOrBlank() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = folder.coverEmoji,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = folder.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "${folder.catalogSources.size} source${if (folder.catalogSources.size != 1) "s" else ""}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingIndicator() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorMessage(error: String) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyMessage() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No items found",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PosterShape.toNuvioPosterShape(): NuvioPosterShape =
|
||||
when (this) {
|
||||
PosterShape.Poster -> NuvioPosterShape.Poster
|
||||
PosterShape.Square -> NuvioPosterShape.Square
|
||||
PosterShape.Landscape -> NuvioPosterShape.Landscape
|
||||
}
|
||||
|
|
@ -37,6 +37,8 @@ import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
|
|||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
import com.nuvio.app.features.watching.domain.buildPlaybackVideoId
|
||||
import com.nuvio.app.features.collection.CollectionRepository
|
||||
import com.nuvio.app.features.home.components.HomeCollectionRowSection
|
||||
import com.nuvio.app.features.watching.domain.isReleasedBy
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
|
|
@ -51,10 +53,12 @@ fun HomeScreen(
|
|||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
|
||||
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null,
|
||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
||||
onFirstCatalogRendered: (() -> Unit)? = null,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
AddonRepository.initialize()
|
||||
CollectionRepository.initialize()
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
WatchedRepository.ensureLoaded()
|
||||
WatchProgressRepository.ensureLoaded()
|
||||
|
|
@ -63,6 +67,7 @@ fun HomeScreen(
|
|||
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||
val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle()
|
||||
val homeSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
|
||||
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
|
||||
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
||||
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
|
||||
|
|
@ -343,6 +348,20 @@ fun HomeScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
// Pin-to-top collection rows
|
||||
val pinnedCollections = collections.filter { it.pinToTop && it.folders.isNotEmpty() }
|
||||
val unpinnedCollections = collections.filter { !it.pinToTop && it.folders.isNotEmpty() }
|
||||
|
||||
pinnedCollections.forEach { collection ->
|
||||
item(key = "collection_${collection.id}") {
|
||||
HomeCollectionRowSection(
|
||||
collection = collection,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
onFolderClick = onFolderClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
count = homeUiState.sections.size,
|
||||
key = { index -> homeUiState.sections[index].key },
|
||||
|
|
@ -362,6 +381,17 @@ fun HomeScreen(
|
|||
onPosterLongClick = onPosterLongClick,
|
||||
)
|
||||
}
|
||||
|
||||
// Unpinned collection rows after catalog sections
|
||||
unpinnedCollections.forEach { collection ->
|
||||
item(key = "collection_${collection.id}") {
|
||||
HomeCollectionRowSection(
|
||||
collection = collection,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
onFolderClick = onFolderClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
package com.nuvio.app.features.home.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||
import com.nuvio.app.features.collection.Collection
|
||||
import com.nuvio.app.features.collection.CollectionFolder
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
|
||||
@Composable
|
||||
fun HomeCollectionRowSection(
|
||||
collection: Collection,
|
||||
modifier: Modifier = Modifier,
|
||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
||||
) {
|
||||
if (collection.folders.isEmpty()) return
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
val sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
|
||||
NuvioShelfSection(
|
||||
title = collection.title,
|
||||
entries = collection.folders,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
headerHorizontalPadding = sectionPadding,
|
||||
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
||||
key = { folder -> "collection_${collection.id}_folder_${folder.id}" },
|
||||
) { folder ->
|
||||
CollectionFolderCard(
|
||||
folder = folder,
|
||||
onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CollectionFolderCard(
|
||||
folder: CollectionFolder,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val shape = folder.posterShape
|
||||
val cardWidth: Dp
|
||||
val aspectRatio: Float
|
||||
|
||||
when (shape) {
|
||||
PosterShape.Poster -> {
|
||||
cardWidth = 110.dp
|
||||
aspectRatio = 0.675f
|
||||
}
|
||||
PosterShape.Landscape -> {
|
||||
cardWidth = 180.dp
|
||||
aspectRatio = 1.77f
|
||||
}
|
||||
PosterShape.Square -> {
|
||||
cardWidth = 120.dp
|
||||
aspectRatio = 1f
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.width(cardWidth),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(aspectRatio)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.then(
|
||||
if (onClick != null) Modifier.clickable(onClick = onClick)
|
||||
else Modifier
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
!folder.coverImageUrl.isNullOrBlank() -> {
|
||||
AsyncImage(
|
||||
model = folder.coverImageUrl,
|
||||
contentDescription = folder.title,
|
||||
modifier = Modifier
|
||||
.matchParentSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
!folder.coverEmoji.isNullOrBlank() -> {
|
||||
Text(
|
||||
text = folder.coverEmoji,
|
||||
fontSize = 36.sp,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
text = folder.title.take(2).uppercase(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!folder.hideTitle) {
|
||||
Text(
|
||||
text = folder.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState
|
|||
import com.nuvio.app.core.auth.isAnonymous
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.collection.CollectionRepository
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.library.LibraryRepository
|
||||
|
|
@ -145,6 +146,7 @@ object ProfileRepository {
|
|||
MdbListSettingsRepository.onProfileChanged()
|
||||
TraktAuthRepository.onProfileChanged()
|
||||
SearchHistoryRepository.onProfileChanged()
|
||||
CollectionRepository.onProfileChanged()
|
||||
}
|
||||
|
||||
suspend fun pushProfiles(profiles: List<ProfilePushPayload>) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.settings
|
|||
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CollectionsBookmark
|
||||
import androidx.compose.material.icons.rounded.Extension
|
||||
import androidx.compose.material.icons.rounded.Hub
|
||||
import androidx.compose.material.icons.rounded.Tune
|
||||
|
|
@ -13,6 +14,7 @@ internal fun LazyListScope.contentDiscoveryContent(
|
|||
onPluginsClick: () -> Unit,
|
||||
onHomescreenClick: () -> Unit,
|
||||
onMetaScreenClick: () -> Unit,
|
||||
onCollectionsClick: () -> Unit = {},
|
||||
) {
|
||||
item {
|
||||
SettingsSection(
|
||||
|
|
@ -59,6 +61,13 @@ internal fun LazyListScope.contentDiscoveryContent(
|
|||
isTablet = isTablet,
|
||||
onClick = onMetaScreenClick,
|
||||
)
|
||||
SettingsNavigationRow(
|
||||
title = "Collections",
|
||||
description = "Create custom catalog groupings with folders shown on Home.",
|
||||
icon = Icons.Rounded.CollectionsBookmark,
|
||||
isTablet = isTablet,
|
||||
onClick = onCollectionsClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ fun SettingsScreen(
|
|||
onAddonsClick: () -> Unit = {},
|
||||
onPluginsClick: () -> Unit = {},
|
||||
onAccountClick: () -> Unit = {},
|
||||
onCollectionsClick: () -> Unit = {},
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
|
|
@ -176,6 +177,7 @@ fun SettingsScreen(
|
|||
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||
onSwitchProfile = onSwitchProfile,
|
||||
onCollectionsClick = onCollectionsClick,
|
||||
)
|
||||
} else {
|
||||
MobileSettingsScreen(
|
||||
|
|
@ -211,6 +213,7 @@ fun SettingsScreen(
|
|||
onAddonsClick = onAddonsClick,
|
||||
onPluginsClick = onPluginsClick,
|
||||
onAccountClick = onAccountClick,
|
||||
onCollectionsClick = onCollectionsClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -250,6 +253,7 @@ private fun MobileSettingsScreen(
|
|||
onAddonsClick: () -> Unit = {},
|
||||
onPluginsClick: () -> Unit = {},
|
||||
onAccountClick: () -> Unit = {},
|
||||
onCollectionsClick: () -> Unit = {},
|
||||
) {
|
||||
NuvioScreen {
|
||||
stickyHeader {
|
||||
|
|
@ -313,6 +317,7 @@ private fun MobileSettingsScreen(
|
|||
onPluginsClick = onPluginsClick,
|
||||
onHomescreenClick = onHomescreenClick,
|
||||
onMetaScreenClick = onMetaScreenClick,
|
||||
onCollectionsClick = onCollectionsClick,
|
||||
)
|
||||
SettingsPage.Addons -> addonsSettingsContent()
|
||||
SettingsPage.Plugins -> if (AppFeaturePolicy.pluginsEnabled) pluginsSettingsContent() else addonsSettingsContent()
|
||||
|
|
@ -376,6 +381,7 @@ private fun TabletSettingsScreen(
|
|||
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||
onSwitchProfile: (() -> Unit)? = null,
|
||||
onCollectionsClick: () -> Unit = {},
|
||||
) {
|
||||
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
|
||||
val activeCategory = SettingsCategory.valueOf(selectedCategory)
|
||||
|
|
@ -508,6 +514,7 @@ private fun TabletSettingsScreen(
|
|||
onPluginsClick = { openInlinePage(SettingsPage.Plugins) },
|
||||
onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) },
|
||||
onMetaScreenClick = { openInlinePage(SettingsPage.MetaScreen) },
|
||||
onCollectionsClick = onCollectionsClick,
|
||||
)
|
||||
SettingsPage.Addons -> addonsSettingsContent()
|
||||
SettingsPage.Plugins -> if (AppFeaturePolicy.pluginsEnabled) pluginsSettingsContent() else addonsSettingsContent()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
actual object CollectionStorage {
|
||||
private const val payloadKey = "collections_payload"
|
||||
|
||||
actual fun loadPayload(): String? =
|
||||
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey))
|
||||
|
||||
actual fun savePayload(payload: String) {
|
||||
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue