From 44bb940bc08132c78037424407fe9c53d01cdd48 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:16:27 +0530 Subject: [PATCH] feat: collections init --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../collection/CollectionStorage.android.kt | 26 + .../commonMain/kotlin/com/nuvio/app/App.kt | 59 ++ .../core/storage/LocalAccountDataCleaner.kt | 2 + .../com/nuvio/app/core/sync/SyncManager.kt | 5 + .../collection/CollectionEditorRepository.kt | 271 ++++++ .../collection/CollectionEditorScreen.kt | 806 ++++++++++++++++++ .../collection/CollectionManagementScreen.kt | 430 ++++++++++ .../features/collection/CollectionModels.kt | 82 ++ .../collection/CollectionRepository.kt | 190 +++++ .../features/collection/CollectionStorage.kt | 6 + .../collection/CollectionSyncService.kt | 140 +++ .../collection/FolderDetailRepository.kt | 200 +++++ .../features/collection/FolderDetailScreen.kt | 326 +++++++ .../com/nuvio/app/features/home/HomeScreen.kt | 30 + .../components/HomeCollectionRowSection.kt | 136 +++ .../features/profiles/ProfileRepository.kt | 2 + .../settings/ContentDiscoverySettingsPage.kt | 9 + .../app/features/settings/SettingsScreen.kt | 7 + .../collection/CollectionStorage.ios.kt | 15 + 20 files changed, 2744 insertions(+) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 1db04fab2..abee26728 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.android.kt new file mode 100644 index 000000000..b3c0938b8 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.android.kt @@ -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() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 9039f81db..b0a1ef449 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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 { + CollectionManagementScreen( + onBack = { navController.popBackStack() }, + onNavigateToEditor = { collectionId -> + navController.navigate(CollectionEditorRoute(collectionId = collectionId)) + }, + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + CollectionEditorScreen( + collectionId = route.collectionId, + onBack = { + CollectionEditorRepository.clear() + navController.popBackStack() + }, + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + 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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index 3aa046ad2..13034a2a2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index 93c34b0df..6e0e79dd9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -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" } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt new file mode 100644 index 000000000..322971916 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -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 = emptyList(), + val isLoading: Boolean = true, + val availableCatalogs: List = 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 = _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 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt new file mode 100644 index 000000000..847bc6384 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -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, + selectedSources: List, + 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)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt new file mode 100644 index 000000000..fe362e345 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt @@ -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(null) } + var showDeleteConfirm by remember { mutableStateOf(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") + } + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt new file mode 100644 index 000000000..5a02e7bb9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -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 = 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 = 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, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt new file mode 100644 index 000000000..b25ba7b0b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -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>(emptyList()) + val collections: StateFlow> = _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>(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) { + _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> { + return runCatching { + val imported = json.decodeFromString>(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>(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 { + 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) { + _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" } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.kt new file mode 100644 index 000000000..d968a3b9a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.collection + +internal expect object CollectionStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt new file mode 100644 index 000000000..aced9be61 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt @@ -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() + 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>(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() + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt new file mode 100644 index 000000000..706a323ea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -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 = 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 = 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 = _uiState.asStateFlow() + + private var loadJobs = mutableListOf() + + 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() + 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 { + 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, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt new file mode 100644 index 000000000..5270e0e98 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt @@ -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 + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 90d31c005..669caee47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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, + ) + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt new file mode 100644 index 000000000..23f578bdb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt @@ -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, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 654e147f3..33c6804b4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt index 202436ede..e2e38ec79 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt @@ -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, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 6e1874e8e..f35e772e6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -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() diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.ios.kt new file mode 100644 index 000000000..fa5ed9970 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.ios.kt @@ -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)) + } +}