feat: collections init

This commit is contained in:
tapframe 2026-04-07 00:16:27 +05:30
parent 1bb3784431
commit 44bb940bc0
20 changed files with 2744 additions and 0 deletions

View file

@ -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)

View file

@ -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()
}
}

View file

@ -116,6 +116,12 @@ import com.nuvio.app.features.settings.AddonsSettingsScreen
import com.nuvio.app.features.settings.PluginsSettingsScreen
import com.nuvio.app.features.settings.AccountSettingsScreen
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.collection.CollectionManagementScreen
import com.nuvio.app.features.collection.CollectionEditorScreen
import com.nuvio.app.features.collection.CollectionEditorRepository
import com.nuvio.app.features.collection.CollectionSyncService
import com.nuvio.app.features.collection.FolderDetailScreen
import com.nuvio.app.features.collection.FolderDetailRepository
import com.nuvio.app.features.streams.StreamContext
import com.nuvio.app.features.streams.StreamContextStore
import com.nuvio.app.features.streams.StreamLinkCacheRepository
@ -177,6 +183,15 @@ object PluginsSettingsRoute
@Serializable
object AccountSettingsRoute
@Serializable
object CollectionsRoute
@Serializable
data class CollectionEditorRoute(val collectionId: String? = null)
@Serializable
data class FolderDetailRoute(val collectionId: String, val folderId: String)
@Serializable
data class StreamRoute(
val type: String,
@ -364,6 +379,9 @@ private fun MainAppContent(
remember {
EpisodeReleaseNotificationsRepository.ensureLoaded()
}
remember {
CollectionSyncService.startObserving()
}
val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
@ -616,6 +634,10 @@ private fun MainAppContent(
}
},
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
onFolderClick = { collectionId, folderId ->
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
},
onInitialHomeContentRendered = { initialHomeReady = true },
)
@ -1105,6 +1127,39 @@ private fun MainAppContent(
onBack = { navController.popBackStack() },
)
}
composable<CollectionsRoute> {
CollectionManagementScreen(
onBack = { navController.popBackStack() },
onNavigateToEditor = { collectionId ->
navController.navigate(CollectionEditorRoute(collectionId = collectionId))
},
)
}
composable<CollectionEditorRoute> { backStackEntry ->
val route = backStackEntry.toRoute<CollectionEditorRoute>()
CollectionEditorScreen(
collectionId = route.collectionId,
onBack = {
CollectionEditorRepository.clear()
navController.popBackStack()
},
)
}
composable<FolderDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<FolderDetailRoute>()
LaunchedEffect(route.collectionId, route.folderId) {
FolderDetailRepository.initialize(route.collectionId, route.folderId)
}
FolderDetailScreen(
onBack = {
FolderDetailRepository.clear()
navController.popBackStack()
},
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
},
)
}
}
NuvioPosterActionSheet(
@ -1235,6 +1290,8 @@ private fun AppTabHost(
onAddonsSettingsClick: () -> Unit = {},
onPluginsSettingsClick: () -> Unit = {},
onAccountSettingsClick: () -> Unit = {},
onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
onInitialHomeContentRendered: () -> Unit = {},
) {
Box(modifier = modifier.fillMaxSize()) {
@ -1248,6 +1305,7 @@ private fun AppTabHost(
onPosterLongClick = onPosterLongClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onFolderClick = onFolderClick,
onFirstCatalogRendered = onInitialHomeContentRendered,
)
}
@ -1281,6 +1339,7 @@ private fun AppTabHost(
onAddonsClick = onAddonsSettingsClick,
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,
onCollectionsClick = onCollectionsSettingsClick,
)
}
}

View file

@ -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()

View file

@ -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" }
}

View file

@ -0,0 +1,271 @@
package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger
import com.nuvio.app.features.home.PosterShape
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
data class CollectionEditorUiState(
val isNew: Boolean = true,
val collectionId: String = "",
val title: String = "",
val backdropImageUrl: String = "",
val pinToTop: Boolean = false,
val viewMode: FolderViewMode = FolderViewMode.TABBED_GRID,
val showAllTab: Boolean = true,
val folders: List<CollectionFolder> = emptyList(),
val isLoading: Boolean = true,
val availableCatalogs: List<AvailableCatalog> = emptyList(),
val editingFolder: CollectionFolder? = null,
val showFolderEditor: Boolean = false,
val showCatalogPicker: Boolean = false,
)
object CollectionEditorRepository {
private val log = Logger.withTag("CollectionEditorRepository")
private val _uiState = MutableStateFlow(CollectionEditorUiState())
val uiState: StateFlow<CollectionEditorUiState> = _uiState.asStateFlow()
@OptIn(ExperimentalUuidApi::class)
fun initialize(collectionId: String?) {
val catalogs = CollectionRepository.getAvailableCatalogs()
if (collectionId != null) {
val existing = CollectionRepository.getCollection(collectionId)
if (existing != null) {
_uiState.value = CollectionEditorUiState(
isNew = false,
collectionId = existing.id,
title = existing.title,
backdropImageUrl = existing.backdropImageUrl.orEmpty(),
pinToTop = existing.pinToTop,
viewMode = existing.folderViewMode,
showAllTab = existing.showAllTab,
folders = existing.folders,
isLoading = false,
availableCatalogs = catalogs,
)
return
}
}
_uiState.value = CollectionEditorUiState(
isNew = true,
collectionId = Uuid.random().toString(),
title = "",
backdropImageUrl = "",
pinToTop = false,
viewMode = FolderViewMode.TABBED_GRID,
showAllTab = true,
folders = emptyList(),
isLoading = false,
availableCatalogs = catalogs,
)
}
fun clear() {
_uiState.value = CollectionEditorUiState()
}
fun setTitle(title: String) {
_uiState.value = _uiState.value.copy(title = title)
}
fun setBackdropImageUrl(url: String) {
_uiState.value = _uiState.value.copy(backdropImageUrl = url)
}
fun setPinToTop(pinToTop: Boolean) {
_uiState.value = _uiState.value.copy(pinToTop = pinToTop)
}
fun setViewMode(viewMode: FolderViewMode) {
_uiState.value = _uiState.value.copy(viewMode = viewMode)
}
fun setShowAllTab(show: Boolean) {
_uiState.value = _uiState.value.copy(showAllTab = show)
}
@OptIn(ExperimentalUuidApi::class)
fun addFolder() {
val newFolder = CollectionFolder(
id = Uuid.random().toString(),
title = "New Folder",
)
_uiState.value = _uiState.value.copy(
editingFolder = newFolder,
showFolderEditor = true,
)
}
fun editFolder(folderId: String) {
val folder = _uiState.value.folders.find { it.id == folderId } ?: return
_uiState.value = _uiState.value.copy(
editingFolder = folder,
showFolderEditor = true,
)
}
fun removeFolder(folderId: String) {
_uiState.value = _uiState.value.copy(
folders = _uiState.value.folders.filter { it.id != folderId },
)
}
fun moveFolderUp(index: Int) {
val list = _uiState.value.folders.toMutableList()
if (index <= 0 || index >= list.size) return
val item = list.removeAt(index)
list.add(index - 1, item)
_uiState.value = _uiState.value.copy(folders = list)
}
fun moveFolderDown(index: Int) {
val list = _uiState.value.folders.toMutableList()
if (index < 0 || index >= list.size - 1) return
val item = list.removeAt(index)
list.add(index + 1, item)
_uiState.value = _uiState.value.copy(folders = list)
}
fun updateFolderTitle(title: String) {
val folder = _uiState.value.editingFolder ?: return
_uiState.value = _uiState.value.copy(editingFolder = folder.copy(title = title))
}
fun updateFolderCoverImage(url: String) {
val folder = _uiState.value.editingFolder ?: return
_uiState.value = _uiState.value.copy(
editingFolder = folder.copy(coverImageUrl = url, coverEmoji = null),
)
}
fun updateFolderCoverEmoji(emoji: String) {
val folder = _uiState.value.editingFolder ?: return
_uiState.value = _uiState.value.copy(
editingFolder = folder.copy(coverEmoji = emoji, coverImageUrl = null),
)
}
fun clearFolderCover() {
val folder = _uiState.value.editingFolder ?: return
_uiState.value = _uiState.value.copy(
editingFolder = folder.copy(coverImageUrl = null, coverEmoji = null),
)
}
fun updateFolderTileShape(shape: PosterShape) {
val folder = _uiState.value.editingFolder ?: return
val shapeStr = when (shape) {
PosterShape.Poster -> "Poster"
PosterShape.Landscape -> "Landscape"
PosterShape.Square -> "Square"
}
_uiState.value = _uiState.value.copy(
editingFolder = folder.copy(tileShape = shapeStr),
)
}
fun updateFolderHideTitle(hide: Boolean) {
val folder = _uiState.value.editingFolder ?: return
_uiState.value = _uiState.value.copy(
editingFolder = folder.copy(hideTitle = hide),
)
}
fun addCatalogSource(catalog: AvailableCatalog) {
val folder = _uiState.value.editingFolder ?: return
val source = CollectionCatalogSource(
addonId = catalog.addonId,
type = catalog.type,
catalogId = catalog.catalogId,
)
if (folder.catalogSources.any {
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
}) return
_uiState.value = _uiState.value.copy(
editingFolder = folder.copy(catalogSources = folder.catalogSources + source),
)
}
fun removeCatalogSource(index: Int) {
val folder = _uiState.value.editingFolder ?: return
if (index !in folder.catalogSources.indices) return
_uiState.value = _uiState.value.copy(
editingFolder = folder.copy(
catalogSources = folder.catalogSources.toMutableList().apply { removeAt(index) },
),
)
}
fun toggleCatalogSource(catalog: AvailableCatalog) {
val folder = _uiState.value.editingFolder ?: return
val existingIndex = folder.catalogSources.indexOfFirst {
it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
}
if (existingIndex >= 0) {
removeCatalogSource(existingIndex)
} else {
addCatalogSource(catalog)
}
}
fun showCatalogPicker() {
_uiState.value = _uiState.value.copy(showCatalogPicker = true)
}
fun hideCatalogPicker() {
_uiState.value = _uiState.value.copy(showCatalogPicker = false)
}
fun saveFolderEdit() {
val folder = _uiState.value.editingFolder ?: return
val existing = _uiState.value.folders
val updated = if (existing.any { it.id == folder.id }) {
existing.map { if (it.id == folder.id) folder else it }
} else {
existing + folder
}
_uiState.value = _uiState.value.copy(
folders = updated,
editingFolder = null,
showFolderEditor = false,
showCatalogPicker = false,
)
}
fun cancelFolderEdit() {
_uiState.value = _uiState.value.copy(
editingFolder = null,
showFolderEditor = false,
showCatalogPicker = false,
)
}
fun save(): Boolean {
val state = _uiState.value
if (state.title.isBlank()) return false
val collection = Collection(
id = state.collectionId,
title = state.title.trim(),
backdropImageUrl = state.backdropImageUrl.ifBlank { null },
pinToTop = state.pinToTop,
viewMode = state.viewMode.name,
showAllTab = state.showAllTab,
folders = state.folders,
)
if (state.isNew) {
CollectionRepository.addCollection(collection)
} else {
CollectionRepository.updateCollection(collection)
}
return true
}
}

View file

@ -0,0 +1,806 @@
package com.nuvio.app.features.collection
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.home.PosterShape
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun CollectionEditorScreen(
collectionId: String?,
onBack: () -> Unit,
) {
val state by CollectionEditorRepository.uiState.collectAsState()
LaunchedEffect(collectionId) {
CollectionEditorRepository.initialize(collectionId)
}
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
if (state.showFolderEditor && state.editingFolder != null) {
FolderEditorSheet(
state = state,
onDismiss = { CollectionEditorRepository.cancelFolderEdit() },
)
}
if (state.showCatalogPicker) {
CatalogPickerSheet(
availableCatalogs = state.availableCatalogs,
selectedSources = state.editingFolder?.catalogSources.orEmpty(),
onToggle = { CollectionEditorRepository.toggleCatalogSource(it) },
onDismiss = { CollectionEditorRepository.hideCatalogPicker() },
)
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(
start = 16.dp,
top = 10.dp + statusBarTop,
end = 16.dp,
bottom = 18.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onBackground,
)
}
Text(
text = if (state.isNew) "New Collection" else "Edit Collection",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
}
// Title
item {
OutlinedTextField(
value = state.title,
onValueChange = { CollectionEditorRepository.setTitle(it) },
label = { Text("Collection Title") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
// Backdrop URL
item {
OutlinedTextField(
value = state.backdropImageUrl,
onValueChange = { CollectionEditorRepository.setBackdropImageUrl(it) },
label = { Text("Backdrop Image URL (optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
// Pin to Top
item {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { CollectionEditorRepository.setPinToTop(!state.pinToTop) }
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Pin to Top",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
text = "Display this collection above regular catalog rows.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = state.pinToTop,
onCheckedChange = { CollectionEditorRepository.setPinToTop(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
),
)
}
}
// View Mode
item {
Column {
Text(
text = "View Mode",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FolderViewMode.entries.forEach { mode ->
FilterChip(
selected = state.viewMode == mode,
onClick = { CollectionEditorRepository.setViewMode(mode) },
label = {
Text(
when (mode) {
FolderViewMode.TABBED_GRID -> "Tabbed Grid"
FolderViewMode.ROWS -> "Rows"
FolderViewMode.FOLLOW_LAYOUT -> "Follow Layout"
}
)
},
leadingIcon = if (state.viewMode == mode) {
{
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
} else null,
)
}
}
}
}
// Show All Tab
item {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { CollectionEditorRepository.setShowAllTab(!state.showAllTab) }
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Show \"All\" Tab",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
text = "Combine all folder catalogs into a single tab.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = state.showAllTab,
onCheckedChange = { CollectionEditorRepository.setShowAllTab(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
),
)
}
}
// Folders Section Header
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "FOLDERS",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
TextButton(onClick = { CollectionEditorRepository.addFolder() }) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Add Folder")
}
}
}
// Folder Items
itemsIndexed(
items = state.folders,
key = { _, folder -> folder.id },
) { index, folder ->
FolderListItem(
folder = folder,
index = index,
totalCount = state.folders.size,
onEdit = { CollectionEditorRepository.editFolder(folder.id) },
onDelete = { CollectionEditorRepository.removeFolder(folder.id) },
onMoveUp = { CollectionEditorRepository.moveFolderUp(index) },
onMoveDown = { CollectionEditorRepository.moveFolderDown(index) },
)
}
if (state.folders.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "No folders yet. Add one to get started.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Save button
item {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
if (CollectionEditorRepository.save()) {
onBack()
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
enabled = state.title.isNotBlank(),
) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(if (state.isNew) "Create Collection" else "Save Changes")
}
}
}
}
@Composable
private fun FolderListItem(
folder: CollectionFolder,
index: Int,
totalCount: Int,
onEdit: () -> Unit,
onDelete: () -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
// Folder cover preview
if (folder.coverEmoji != null) {
Surface(
modifier = Modifier.size(40.dp),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = folder.coverEmoji, style = MaterialTheme.typography.titleLarge)
}
}
Spacer(modifier = Modifier.width(12.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = folder.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${folder.catalogSources.size} source${if (folder.catalogSources.size != 1) "s" else ""} · ${folder.posterShape.name}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(
onClick = onMoveUp,
enabled = index > 0,
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Rounded.ArrowUpward,
contentDescription = "Move up",
modifier = Modifier.size(16.dp).alpha(if (index > 0) 1f else 0.3f),
)
}
IconButton(
onClick = onMoveDown,
enabled = index < totalCount - 1,
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Rounded.ArrowDownward,
contentDescription = "Move down",
modifier = Modifier.size(16.dp).alpha(if (index < totalCount - 1) 1f else 0.3f),
)
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Rounded.Edit,
contentDescription = "Edit",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = "Delete",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun FolderEditorSheet(
state: CollectionEditorUiState,
onDismiss: () -> Unit,
) {
val folder = state.editingFolder ?: return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
) {
LazyColumn(
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
Text(
text = "Edit Folder",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
}
item {
OutlinedTextField(
value = folder.title,
onValueChange = { CollectionEditorRepository.updateFolderTitle(it) },
label = { Text("Folder Title") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
// Cover (emoji or image url)
item {
Column {
Text(
text = "Cover",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = folder.coverEmoji == null && folder.coverImageUrl == null,
onClick = { CollectionEditorRepository.clearFolderCover() },
label = { Text("None") },
)
FilterChip(
selected = folder.coverEmoji != null,
onClick = {
if (folder.coverEmoji == null) {
CollectionEditorRepository.updateFolderCoverEmoji("📁")
}
},
label = { Text("Emoji") },
)
FilterChip(
selected = folder.coverImageUrl != null,
onClick = {
if (folder.coverImageUrl == null) {
CollectionEditorRepository.updateFolderCoverImage("")
}
},
label = { Text("Image") },
)
}
if (folder.coverEmoji != null) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = folder.coverEmoji,
onValueChange = { CollectionEditorRepository.updateFolderCoverEmoji(it) },
label = { Text("Emoji") },
modifier = Modifier.width(100.dp),
singleLine = true,
)
}
if (folder.coverImageUrl != null) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = folder.coverImageUrl,
onValueChange = { CollectionEditorRepository.updateFolderCoverImage(it) },
label = { Text("Image URL") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
}
}
// Tile Shape
item {
Column {
Text(
text = "Tile Shape",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.height(8.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PosterShape.entries.forEach { shape ->
FilterChip(
selected = folder.posterShape == shape,
onClick = { CollectionEditorRepository.updateFolderTileShape(shape) },
label = { Text(shape.name) },
leadingIcon = if (folder.posterShape == shape) {
{
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
} else null,
)
}
}
}
}
// Hide Title
item {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { CollectionEditorRepository.updateFolderHideTitle(!folder.hideTitle) }
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Hide Title",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Switch(
checked = folder.hideTitle,
onCheckedChange = { CollectionEditorRepository.updateFolderHideTitle(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
),
)
}
}
// Catalog Sources
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "CATALOG SOURCES",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Add")
}
}
}
itemsIndexed(folder.catalogSources) { index, source ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
),
shape = RoundedCornerShape(8.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "${source.catalogId} (${source.type})",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = source.addonId,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(
onClick = { CollectionEditorRepository.removeCatalogSource(index) },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Remove",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
if (folder.catalogSources.isEmpty()) {
item {
Text(
text = "No catalog sources. Tap \"Add\" to select from installed addons.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
// Save / Cancel
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
TextButton(
onClick = { CollectionEditorRepository.cancelFolderEdit() },
modifier = Modifier.weight(1f),
) {
Text("Cancel")
}
Button(
onClick = { CollectionEditorRepository.saveFolderEdit() },
modifier = Modifier.weight(1f),
enabled = folder.title.isNotBlank(),
shape = RoundedCornerShape(12.dp),
) {
Text("Save Folder")
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CatalogPickerSheet(
availableCatalogs: List<AvailableCatalog>,
selectedSources: List<CollectionCatalogSource>,
onToggle: (AvailableCatalog) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
) {
LazyColumn(
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Select Catalog Sources",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
TextButton(onClick = onDismiss) {
Text("Done")
}
}
}
val grouped = availableCatalogs.groupBy { it.addonName }
grouped.forEach { (addonName, catalogs) ->
item {
Text(
text = addonName,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
}
catalogs.forEach { catalog ->
val isSelected = selectedSources.any {
it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
}
item(key = "${catalog.addonId}:${catalog.type}:${catalog.catalogId}") {
val bgColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
}
val borderColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
} else {
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(bgColor)
.border(1.dp, borderColor, RoundedCornerShape(10.dp))
.clickable { onToggle(catalog) }
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = catalog.catalogName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
Text(
text = catalog.type.replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
item {
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}

View file

@ -0,0 +1,430 @@
package com.nuvio.app.features.collection
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.ContentPaste
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Folder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun CollectionManagementScreen(
onBack: () -> Unit,
onNavigateToEditor: (String?) -> Unit,
) {
val collections by CollectionRepository.collections.collectAsState()
val clipboardManager = LocalClipboardManager.current
var showImportDialog by remember { mutableStateOf(false) }
var importText by remember { mutableStateOf("") }
var importError by remember { mutableStateOf<String?>(null) }
var showDeleteConfirm by remember { mutableStateOf<String?>(null) }
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(
start = 16.dp,
top = 10.dp + statusBarTop,
end = 16.dp,
bottom = 18.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onBackground,
)
}
Text(
text = "Collections",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onBackground,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = {
val json = CollectionRepository.exportToJson()
clipboardManager.setText(AnnotatedString(json))
}) {
Icon(
imageVector = Icons.Rounded.ContentCopy,
contentDescription = "Copy JSON",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { showImportDialog = true }) {
Icon(
imageVector = Icons.Rounded.ContentPaste,
contentDescription = "Import",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${collections.size} collection${if (collections.size != 1) "s" else ""}, " +
"${collections.sumOf { it.folders.size }} folder${if (collections.sumOf { it.folders.size } != 1) "s" else ""}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
item {
Button(
onClick = { onNavigateToEditor(null) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text("New Collection")
}
}
itemsIndexed(
items = collections,
key = { _, collection -> collection.id },
) { index, collection ->
CollectionListItem(
collection = collection,
index = index,
totalCount = collections.size,
onEdit = { onNavigateToEditor(collection.id) },
onDelete = { showDeleteConfirm = collection.id },
onMoveUp = { CollectionRepository.moveUp(index) },
onMoveDown = { CollectionRepository.moveDown(index) },
)
}
if (collections.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Rounded.Folder,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "No collections yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Create one to organize your catalogs.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
}
}
}
}
if (showImportDialog) {
ImportDialog(
importText = importText,
importError = importError,
onTextChange = {
importText = it
importError = null
},
onConfirm = {
val result = CollectionRepository.validateJson(importText)
if (result.valid) {
CollectionRepository.importFromJson(importText)
showImportDialog = false
importText = ""
importError = null
} else {
importError = result.error
}
},
onDismiss = {
showImportDialog = false
importText = ""
importError = null
},
)
}
if (showDeleteConfirm != null) {
val collectionId = showDeleteConfirm!!
val collection = collections.find { it.id == collectionId }
AlertDialog(
onDismissRequest = { showDeleteConfirm = null },
title = { Text("Delete Collection") },
text = {
Text("Delete \"${collection?.title ?: ""}\"? This cannot be undone.")
},
confirmButton = {
Button(
onClick = {
CollectionRepository.removeCollection(collectionId)
showDeleteConfirm = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = null }) {
Text("Cancel")
}
},
)
}
}
@Composable
private fun CollectionListItem(
collection: Collection,
index: Int,
totalCount: Int,
onEdit: () -> Unit,
onDelete: () -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = collection.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}" +
if (collection.pinToTop) " · Pinned" else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(10.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
IconButton(
onClick = onMoveUp,
enabled = index > 0,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.ArrowUpward,
contentDescription = "Move up",
modifier = Modifier.size(18.dp).alpha(if (index > 0) 1f else 0.3f),
)
}
IconButton(
onClick = onMoveDown,
enabled = index < totalCount - 1,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.ArrowDownward,
contentDescription = "Move down",
modifier = Modifier.size(18.dp).alpha(if (index < totalCount - 1) 1f else 0.3f),
)
}
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = onEdit,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.Edit,
contentDescription = "Edit",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(
onClick = onDelete,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = "Delete",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
@Composable
private fun ImportDialog(
importText: String,
importError: String?,
onTextChange: (String) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Import Collections") },
text = {
Column {
Text(
text = "Paste your collections JSON below.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = importText,
onValueChange = onTextChange,
modifier = Modifier
.fillMaxWidth()
.height(160.dp),
label = { Text("JSON") },
isError = importError != null,
supportingText = importError?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) }
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onConfirm() }),
maxLines = 10,
)
}
},
confirmButton = {
Button(
onClick = onConfirm,
enabled = importText.isNotBlank(),
) {
Text("Import")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}

View file

@ -0,0 +1,82 @@
package com.nuvio.app.features.collection
import androidx.compose.runtime.Immutable
import com.nuvio.app.features.home.PosterShape
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
enum class FolderViewMode {
TABBED_GRID,
ROWS,
FOLLOW_LAYOUT;
companion object {
fun fromString(value: String): FolderViewMode =
entries.firstOrNull { it.name.equals(value, ignoreCase = true) } ?: TABBED_GRID
}
}
@Immutable
@Serializable
data class CollectionCatalogSource(
val addonId: String,
val type: String,
val catalogId: String,
)
@Immutable
@Serializable
data class CollectionFolder(
val id: String,
val title: String,
val coverImageUrl: String? = null,
val coverEmoji: String? = null,
val tileShape: String = "Poster",
val hideTitle: Boolean = false,
val catalogSources: List<CollectionCatalogSource> = emptyList(),
) {
val posterShape: PosterShape
get() = when (tileShape.lowercase()) {
"poster" -> PosterShape.Poster
"landscape", "wide" -> PosterShape.Landscape
"square" -> PosterShape.Square
else -> PosterShape.Poster
}
}
@Immutable
@Serializable
data class Collection(
val id: String,
val title: String,
val backdropImageUrl: String? = null,
val pinToTop: Boolean = false,
val viewMode: String = "TABBED_GRID",
val showAllTab: Boolean = true,
val folders: List<CollectionFolder> = emptyList(),
) {
val folderViewMode: FolderViewMode
get() = FolderViewMode.fromString(viewMode)
}
data class AvailableCatalog(
val addonId: String,
val addonName: String,
val type: String,
val catalogId: String,
val catalogName: String,
)
@Serializable
data class SupabaseCollectionBlob(
@SerialName("profile_id") val profileId: Int = 1,
@SerialName("collections_json") val collectionsJson: kotlinx.serialization.json.JsonElement = kotlinx.serialization.json.JsonArray(emptyList()),
@SerialName("updated_at") val updatedAt: String? = null,
)
data class ValidationResult(
val valid: Boolean,
val error: String? = null,
val collectionCount: Int = 0,
val folderCount: Int = 0,
)

View file

@ -0,0 +1,190 @@
package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.ManagedAddon
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
object CollectionRepository {
private val log = Logger.withTag("CollectionRepository")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _collections = MutableStateFlow<List<Collection>>(emptyList())
val collections: StateFlow<List<Collection>> = _collections.asStateFlow()
private var hasLoaded = false
fun initialize() {
if (hasLoaded) return
hasLoaded = true
val payload = CollectionStorage.loadPayload()
if (payload.isNullOrBlank()) return
runCatching {
_collections.value = json.decodeFromString<List<Collection>>(payload)
}.onFailure { e ->
log.e(e) { "Failed to load collections from storage" }
}
}
fun onProfileChanged() {
hasLoaded = false
_collections.value = emptyList()
}
fun clearLocalState() {
hasLoaded = false
_collections.value = emptyList()
}
fun getCollection(id: String): Collection? =
_collections.value.find { it.id == id }
fun addCollection(collection: Collection) {
ensureLoaded()
_collections.value = _collections.value + collection
persist()
}
fun updateCollection(collection: Collection) {
ensureLoaded()
_collections.value = _collections.value.map {
if (it.id == collection.id) collection else it
}
persist()
}
fun removeCollection(collectionId: String) {
ensureLoaded()
_collections.value = _collections.value.filter { it.id != collectionId }
persist()
}
fun setCollections(collections: List<Collection>) {
_collections.value = collections
persist()
}
fun moveUp(index: Int) {
ensureLoaded()
val list = _collections.value.toMutableList()
if (index <= 0 || index >= list.size) return
val item = list.removeAt(index)
list.add(index - 1, item)
_collections.value = list
persist()
}
fun moveDown(index: Int) {
ensureLoaded()
val list = _collections.value.toMutableList()
if (index < 0 || index >= list.size - 1) return
val item = list.removeAt(index)
list.add(index + 1, item)
_collections.value = list
persist()
}
fun exportToJson(): String {
ensureLoaded()
return json.encodeToString(_collections.value)
}
fun importFromJson(jsonString: String): Result<List<Collection>> {
return runCatching {
val imported = json.decodeFromString<List<Collection>>(jsonString)
_collections.value = imported
persist()
imported
}
}
fun validateJson(jsonString: String): ValidationResult {
if (jsonString.isBlank()) {
return ValidationResult(valid = false, error = "JSON is empty.")
}
return try {
val collections = json.decodeFromString<List<Collection>>(jsonString)
var totalFolders = 0
collections.forEachIndexed { ci, c ->
if (c.id.isBlank()) {
return ValidationResult(valid = false, error = "Collection ${ci + 1} has blank id.")
}
if (c.title.isBlank()) {
return ValidationResult(valid = false, error = "Collection '${c.id}' has blank title.")
}
c.folders.forEachIndexed { fi, f ->
if (f.id.isBlank()) {
return ValidationResult(valid = false, error = "Folder ${fi + 1} in '${c.title}' has blank id.")
}
if (f.title.isBlank()) {
return ValidationResult(valid = false, error = "Folder '${f.id}' in '${c.title}' has blank title.")
}
f.catalogSources.forEachIndexed { si, s ->
if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) {
return ValidationResult(valid = false, error = "Source ${si + 1} in folder '${f.title}' has blank fields.")
}
}
totalFolders++
}
}
ValidationResult(
valid = true,
collectionCount = collections.size,
folderCount = totalFolders,
)
} catch (e: Exception) {
ValidationResult(valid = false, error = "Invalid JSON: ${e.message}")
}
}
@OptIn(ExperimentalUuidApi::class)
fun generateId(): String = Uuid.random().toString()
fun getAvailableCatalogs(): List<AvailableCatalog> {
val addons = AddonRepository.uiState.value.addons
return addons.mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
addon to manifest
}.flatMap { (addon, manifest) ->
manifest.catalogs
.filter { catalog -> catalog.extra.none { it.isRequired } }
.map { catalog ->
AvailableCatalog(
addonId = manifest.id,
addonName = manifest.name,
type = catalog.type,
catalogId = catalog.id,
catalogName = catalog.name,
)
}
}
}
internal fun applyFromRemote(collections: List<Collection>) {
_collections.value = collections
persist()
}
private fun ensureLoaded() {
if (!hasLoaded) initialize()
}
private fun persist() {
runCatching {
CollectionStorage.savePayload(json.encodeToString(_collections.value))
}.onFailure { e ->
log.e(e) { "Failed to persist collections" }
}
}
}

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.collection
internal expect object CollectionStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -0,0 +1,140 @@
package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.profiles.ProfileRepository
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.rpc
import kotlin.concurrent.Volatile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
object CollectionSyncService {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("CollectionSyncService")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private const val PUSH_DEBOUNCE_MS = 1500L
@Volatile
var isSyncingFromRemote: Boolean = false
private var pushJob: Job? = null
private var observeJob: Job? = null
fun startObserving() {
if (observeJob?.isActive == true) return
observeLocalChangesAndPush()
}
suspend fun pullFromServer(profileId: Int) {
runCatching {
val params = buildJsonObject {
put("p_profile_id", profileId)
}
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_collections", params)
val blobs = result.decodeList<SupabaseCollectionBlob>()
val blob = blobs.firstOrNull()
if (blob == null) {
log.i { "pullFromServer — no remote collections found" }
return
}
val remoteJson = blob.collectionsJson.toString()
val localJson = CollectionRepository.exportToJson()
if (remoteJson == "[]" || remoteJson == "null") {
val currentCollections = CollectionRepository.collections.value
if (currentCollections.isNotEmpty()) {
log.i { "pullFromServer — remote empty, preserving local ${currentCollections.size} collections" }
return
}
}
if (remoteJson == localJson) {
log.d { "pullFromServer — remote matches local, no update needed" }
return
}
val remoteCollections = runCatching {
json.decodeFromString<List<Collection>>(remoteJson)
}.getOrNull()
if (remoteCollections != null) {
isSyncingFromRemote = true
CollectionRepository.applyFromRemote(remoteCollections)
isSyncingFromRemote = false
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
} else {
log.w { "pullFromServer — failed to parse remote collections JSON" }
}
}.onFailure { e ->
isSyncingFromRemote = false
log.e(e) { "pullFromServer — FAILED" }
}
}
fun triggerPush() {
pushJob?.cancel()
pushJob = scope.launch {
delay(500)
if (isSyncingFromRemote) return@launch
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@launch
pushToRemote()
}
}
private suspend fun pushToRemote() {
runCatching {
val profileId = ProfileRepository.activeProfileId
val collectionsJson = CollectionRepository.exportToJson()
val jsonElement = runCatching {
json.parseToJsonElement(collectionsJson)
}.getOrDefault(JsonArray(emptyList()))
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_collections_json", jsonElement)
}
SupabaseProvider.client.postgrest.rpc("sync_push_collections", params)
log.d { "pushToRemote — success" }
}.onFailure { e ->
log.e(e) { "pushToRemote — FAILED" }
}
}
@OptIn(FlowPreview::class)
private fun observeLocalChangesAndPush() {
observeJob = scope.launch {
CollectionRepository.collections
.drop(1)
.distinctUntilChanged()
.debounce(PUSH_DEBOUNCE_MS)
.collect {
if (isSyncingFromRemote) return@collect
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@collect
pushToRemote()
}
}
}
}

View file

@ -0,0 +1,200 @@
package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class FolderTab(
val label: String,
val typeLabel: String = "",
val items: List<MetaPreview> = emptyList(),
val isLoading: Boolean = true,
val error: String? = null,
val isAllTab: Boolean = false,
)
data class FolderDetailUiState(
val folder: CollectionFolder? = null,
val collectionTitle: String = "",
val viewMode: FolderViewMode = FolderViewMode.TABBED_GRID,
val tabs: List<FolderTab> = emptyList(),
val selectedTabIndex: Int = 0,
val isLoading: Boolean = true,
val showAllTab: Boolean = true,
)
object FolderDetailRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("FolderDetailRepository")
private val _uiState = MutableStateFlow(FolderDetailUiState())
val uiState: StateFlow<FolderDetailUiState> = _uiState.asStateFlow()
private var loadJobs = mutableListOf<Job>()
fun initialize(collectionId: String, folderId: String) {
clear()
val collection = CollectionRepository.getCollection(collectionId)
if (collection == null) {
_uiState.value = FolderDetailUiState(isLoading = false)
return
}
val folder = collection.folders.find { it.id == folderId }
if (folder == null) {
_uiState.value = FolderDetailUiState(isLoading = false)
return
}
val showAll = collection.showAllTab && folder.catalogSources.size > 1
val addons = AddonRepository.uiState.value.addons
val tabs = buildList {
if (showAll) {
add(FolderTab(label = "All", isAllTab = true, isLoading = true))
}
folder.catalogSources.forEach { source ->
val addon = addons.find { it.manifest?.id == source.addonId }
val catalog = addon?.manifest?.catalogs?.find {
it.id == source.catalogId && it.type == source.type
}
val label = catalog?.name ?: source.catalogId
val typeLabel = source.type.replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
add(
FolderTab(
label = "$label ($typeLabel)",
typeLabel = typeLabel,
isLoading = true,
),
)
}
}
_uiState.value = FolderDetailUiState(
folder = folder,
collectionTitle = collection.title,
viewMode = collection.folderViewMode,
tabs = tabs,
selectedTabIndex = 0,
isLoading = true,
showAllTab = showAll,
)
// Load catalog data for each source
folder.catalogSources.forEachIndexed { sourceIndex, source ->
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
val addon = addons.find { it.manifest?.id == source.addonId }
if (addon == null) {
updateTab(tabIndex) { it.copy(isLoading = false, error = "Addon not found: ${source.addonId}") }
return@forEachIndexed
}
val job = scope.launch {
runCatching {
val page = fetchCatalogPage(
manifestUrl = addon.manifestUrl,
type = source.type,
catalogId = source.catalogId,
)
updateTab(tabIndex) { it.copy(items = page.items, isLoading = false) }
rebuildAllTab()
}.onFailure { e ->
log.e(e) { "Failed to load catalog ${source.catalogId} from ${source.addonId}" }
updateTab(tabIndex) { it.copy(isLoading = false, error = e.message) }
rebuildAllTab()
}
}
loadJobs.add(job)
}
// If no sources, mark as done
if (folder.catalogSources.isEmpty()) {
_uiState.value = _uiState.value.copy(isLoading = false)
}
}
fun selectTab(index: Int) {
_uiState.value = _uiState.value.copy(selectedTabIndex = index)
}
fun clear() {
loadJobs.forEach { it.cancel() }
loadJobs.clear()
_uiState.value = FolderDetailUiState()
}
private fun updateTab(index: Int, transform: (FolderTab) -> FolderTab) {
val current = _uiState.value
val updatedTabs = current.tabs.toMutableList()
if (index !in updatedTabs.indices) return
updatedTabs[index] = transform(updatedTabs[index])
val allDone = updatedTabs.none { !it.isAllTab && it.isLoading }
_uiState.value = current.copy(
tabs = updatedTabs,
isLoading = !allDone,
)
}
private fun rebuildAllTab() {
val current = _uiState.value
if (!current.showAllTab) return
val sourceTabs = current.tabs.filter { !it.isAllTab }
if (sourceTabs.any { it.isLoading }) return
// Round-robin merge
val merged = mutableListOf<MetaPreview>()
val iterators = sourceTabs.map { it.items.iterator() }
var hasMore = true
while (hasMore) {
hasMore = false
for (iterator in iterators) {
if (iterator.hasNext()) {
merged.add(iterator.next())
hasMore = true
}
}
}
val updatedTabs = current.tabs.toMutableList()
val allTabIndex = updatedTabs.indexOfFirst { it.isAllTab }
if (allTabIndex >= 0) {
updatedTabs[allTabIndex] = updatedTabs[allTabIndex].copy(
items = merged,
isLoading = false,
)
}
_uiState.value = current.copy(tabs = updatedTabs)
}
fun getCatalogSectionsForRows(): List<HomeCatalogSection> {
val current = _uiState.value
val folder = current.folder ?: return emptyList()
return current.tabs.filter { !it.isAllTab && it.items.isNotEmpty() }.map { tab ->
HomeCatalogSection(
key = "folder_${folder.id}_${tab.label}",
title = tab.label,
subtitle = tab.typeLabel,
addonName = "",
type = "",
manifestUrl = "",
catalogId = "",
items = tab.items,
)
}
}
}

View file

@ -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
}

View file

@ -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,
)
}
}
}
}
}

View file

@ -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,
)
}
}
}

View file

@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.auth.isAnonymous
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.library.LibraryRepository
@ -145,6 +146,7 @@ object ProfileRepository {
MdbListSettingsRepository.onProfileChanged()
TraktAuthRepository.onProfileChanged()
SearchHistoryRepository.onProfileChanged()
CollectionRepository.onProfileChanged()
}
suspend fun pushProfiles(profiles: List<ProfilePushPayload>) {

View file

@ -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,
)
}
}
}

View file

@ -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()

View file

@ -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))
}
}