feat: collections reordering

This commit is contained in:
tapframe 2026-04-07 19:21:50 +05:30
parent 74d07804dd
commit 9f3ead5221
6 changed files with 256 additions and 90 deletions

View file

@ -1,8 +1,11 @@
package com.nuvio.app.core.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -42,6 +45,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -430,3 +434,44 @@ fun NuvioStatusModal(
}
}
}
@Composable
fun NuvioPinnedCollectionToast(
visible: Boolean,
onDismiss: () -> Unit,
message: String = "Remove pin to top from collection to move",
) {
LaunchedEffect(visible) {
if (visible) {
kotlinx.coroutines.delay(2500L)
onDismiss()
}
}
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideInVertically { -it },
exit = fadeOut() + slideOutVertically { -it },
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.inverseSurface,
tonalElevation = 6.dp,
shadowElevation = 4.dp,
) {
Text(
text = message,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
}
}
}
}

View file

@ -1,6 +1,8 @@
package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.collection.Collection
import com.nuvio.app.features.collection.CollectionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -17,6 +19,9 @@ data class HomeCatalogSettingsItem(
val enabled: Boolean = true,
val heroSourceEnabled: Boolean = true,
val order: Int = 0,
val isCollection: Boolean = false,
val collectionId: String? = null,
val isPinnedToTop: Boolean = false,
) {
val displayTitle: String
get() = customTitle.ifBlank { defaultTitle }
@ -78,6 +83,7 @@ object HomeCatalogSettingsRepository {
private var hasLoaded = false
private var definitions: List<HomeCatalogDefinition> = emptyList()
private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList()
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
private var heroEnabled = true
@ -86,12 +92,14 @@ object HomeCatalogSettingsRepository {
preferences.clear()
heroEnabled = true
definitions = emptyList()
collectionDefinitions = emptyList()
_uiState.value = HomeCatalogSettingsUiState()
}
fun clearLocalState() {
hasLoaded = false
definitions = emptyList()
collectionDefinitions = emptyList()
preferences.clear()
heroEnabled = true
_uiState.value = HomeCatalogSettingsUiState()
@ -100,7 +108,8 @@ object HomeCatalogSettingsRepository {
fun syncCatalogs(addons: List<ManagedAddon>) {
ensureLoaded()
definitions = buildHomeCatalogDefinitions(addons)
if (definitions.isEmpty()) {
collectionDefinitions = buildCollectionDefinitions(CollectionRepository.collections.value)
if (definitions.isEmpty() && collectionDefinitions.isEmpty()) {
publish()
return
}
@ -109,6 +118,14 @@ object HomeCatalogSettingsRepository {
persist()
}
fun syncCollections(collections: List<Collection>) {
ensureLoaded()
collectionDefinitions = buildCollectionDefinitions(collections)
normalizePreferences()
publish()
persist()
}
internal fun snapshot(): HomeCatalogSettingsSnapshot {
ensureLoaded()
return HomeCatalogSettingsSnapshot(
@ -176,13 +193,11 @@ object HomeCatalogSettingsRepository {
fun moveByIndex(fromIndex: Int, toIndex: Int) {
ensureLoaded()
if (definitions.isEmpty()) return
val orderedKeys = definitions
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
.map { it.key }
.toMutableList()
if (fromIndex !in orderedKeys.indices || toIndex !in orderedKeys.indices) return
val allKeys = allOrderedKeys()
if (allKeys.isEmpty()) return
if (fromIndex !in allKeys.indices || toIndex !in allKeys.indices) return
if (fromIndex == toIndex) return
val orderedKeys = allKeys.toMutableList()
orderedKeys.add(toIndex, orderedKeys.removeAt(fromIndex))
orderedKeys.forEachIndexed { index, itemKey ->
val current = preferences[itemKey] ?: return@forEachIndexed
@ -219,14 +234,19 @@ object HomeCatalogSettingsRepository {
private fun normalizePreferences() {
val current = preferences
val orderedDefinitions = definitions.mapIndexed { defaultIndex, definition ->
data class UnifiedEntry(val key: String, val isCollection: Boolean)
val catalogEntries = definitions.map { UnifiedEntry(it.key, false) }
val collectionEntries = collectionDefinitions.map { UnifiedEntry(it.key, true) }
val allEntries = catalogEntries + collectionEntries
val orderedEntries = allEntries.mapIndexed { defaultIndex, entry ->
Triple(
definition,
current[definition.key]?.order ?: defaultIndex,
entry,
current[entry.key]?.order ?: (definitions.size + defaultIndex),
defaultIndex,
)
}.sortedWith(
compareBy<Triple<HomeCatalogDefinition, Int, Int>>(
compareBy<Triple<UnifiedEntry, Int, Int>>(
{ it.second },
{ it.third },
),
@ -234,15 +254,19 @@ object HomeCatalogSettingsRepository {
val normalized = mutableMapOf<String, StoredHomeCatalogPreference>()
var enabledHeroSourceCount = 0
orderedDefinitions.forEachIndexed { index, definition ->
val stored = current[definition.key]
val heroSourceEnabled = (stored?.heroSourceEnabled ?: true) &&
enabledHeroSourceCount < HERO_SOURCE_SELECTION_LIMIT
orderedEntries.forEachIndexed { index, entry ->
val stored = current[entry.key]
val heroSourceEnabled = if (entry.isCollection) {
false
} else {
(stored?.heroSourceEnabled ?: true) &&
enabledHeroSourceCount < HERO_SOURCE_SELECTION_LIMIT
}
if (heroSourceEnabled) {
enabledHeroSourceCount += 1
}
normalized[definition.key] = StoredHomeCatalogPreference(
key = definition.key,
normalized[entry.key] = StoredHomeCatalogPreference(
key = entry.key,
customTitle = stored?.customTitle.orEmpty(),
enabled = stored?.enabled ?: true,
heroSourceEnabled = heroSourceEnabled,
@ -253,8 +277,8 @@ object HomeCatalogSettingsRepository {
}
private fun publish() {
val items = definitions
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
val collectionMap = collectionDefinitions.associateBy { it.key }
val catalogItems = definitions
.map { definition ->
val preference = preferences[definition.key]
HomeCatalogSettingsItem(
@ -268,6 +292,25 @@ object HomeCatalogSettingsRepository {
)
}
val collectionItems = collectionDefinitions.map { colDef ->
val preference = preferences[colDef.key]
HomeCatalogSettingsItem(
key = colDef.key,
defaultTitle = colDef.title,
addonName = colDef.subtitle,
customTitle = preference?.customTitle.orEmpty(),
enabled = preference?.enabled ?: true,
heroSourceEnabled = false,
order = preference?.order ?: 0,
isCollection = true,
collectionId = colDef.collectionId,
isPinnedToTop = colDef.isPinnedToTop,
)
}
val items = (catalogItems + collectionItems)
.sortedBy { it.order }
_uiState.value = HomeCatalogSettingsUiState(
heroEnabled = heroEnabled,
items = items,
@ -307,12 +350,8 @@ object HomeCatalogSettingsRepository {
direction: Int,
) {
ensureLoaded()
if (definitions.isEmpty()) return
val orderedKeys = definitions
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
.map { it.key }
.toMutableList()
val orderedKeys = allOrderedKeys().toMutableList()
if (orderedKeys.isEmpty()) return
val currentIndex = orderedKeys.indexOf(key)
if (currentIndex == -1) return
@ -332,4 +371,30 @@ object HomeCatalogSettingsRepository {
persist()
HomeRepository.applyCurrentSettings()
}
private fun allOrderedKeys(): List<String> {
val catalogKeys = definitions.map { it.key }
val collectionKeys = collectionDefinitions.map { it.key }
return (catalogKeys + collectionKeys)
.sortedBy { key -> preferences[key]?.order ?: Int.MAX_VALUE }
}
}
internal data class CollectionCatalogDefinition(
val key: String,
val collectionId: String,
val title: String,
val subtitle: String,
val isPinnedToTop: Boolean,
)
internal fun buildCollectionDefinitions(collections: List<Collection>): List<CollectionCatalogDefinition> =
collections.filter { it.folders.isNotEmpty() }.map { collection ->
CollectionCatalogDefinition(
key = "collection_${collection.id}",
collectionId = collection.id,
title = collection.title,
subtitle = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}",
isPinnedToTop = collection.pinToTop,
)
}

View file

@ -187,6 +187,10 @@ fun HomeScreen(
HomeRepository.refresh(addonsUiState.addons)
}
LaunchedEffect(collections) {
HomeCatalogSettingsRepository.syncCollections(collections)
}
LaunchedEffect(completedSeriesCandidates, metaProviderKey) {
if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap()
@ -368,48 +372,43 @@ 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,
)
}
}
val collectionsMap = collections.filter { it.folders.isNotEmpty() }
.associateBy { "collection_${it.id}" }
val sectionsMap = homeUiState.sections.associateBy { it.key }
val orderedItems = homeSettingsUiState.items.filter { it.enabled }
items(
count = homeUiState.sections.size,
key = { index -> homeUiState.sections[index].key },
) { index ->
val section = homeUiState.sections[index]
HomeCatalogRowSection(
section = section,
entries = section.items.take(HOME_CATALOG_PREVIEW_LIMIT),
modifier = Modifier.padding(bottom = 12.dp),
onViewAllClick = if (section.canOpenCatalog(HOME_CATALOG_PREVIEW_LIMIT)) {
onCatalogClick?.let { { it(section) } }
} else {
null
},
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onPosterClick,
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,
)
orderedItems.forEach { settingsItem ->
if (settingsItem.isCollection) {
val collection = collectionsMap[settingsItem.key]
if (collection != null) {
item(key = settingsItem.key) {
HomeCollectionRowSection(
collection = collection,
modifier = Modifier.padding(bottom = 12.dp),
onFolderClick = onFolderClick,
)
}
}
} else {
val section = sectionsMap[settingsItem.key]
if (section != null && section.items.isNotEmpty()) {
item(key = settingsItem.key) {
HomeCatalogRowSection(
section = section,
entries = section.items.take(HOME_CATALOG_PREVIEW_LIMIT),
modifier = Modifier.padding(bottom = 12.dp),
onViewAllClick = if (section.canOpenCatalog(HOME_CATALOG_PREVIEW_LIMIT)) {
onCatalogClick?.let { { it(section) } }
} else {
null
},
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
}
}
}
}
}

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.NuvioActionLabel
import com.nuvio.app.core.ui.NuvioPinnedCollectionToast
import com.nuvio.app.features.home.HomeCatalogSettingsItem
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.components.HomeEmptyStateCard
@ -71,7 +72,8 @@ internal fun LazyListScope.homescreenSettingsContent(
}
}
item {
if (heroEnabled && items.isNotEmpty()) {
val catalogOnlyItems = items.filter { !it.isCollection }
if (heroEnabled && catalogOnlyItems.isNotEmpty()) {
var heroSourcesExpanded by remember { mutableStateOf(false) }
SettingsSection(
title = "HERO SOURCES",
@ -79,7 +81,7 @@ internal fun LazyListScope.homescreenSettingsContent(
) {
HeroSourcesDropdown(
isTablet = isTablet,
items = items,
items = catalogOnlyItems,
selectedHeroSourceCount = selectedHeroSourceCount,
expanded = heroSourcesExpanded,
onExpandedChange = { heroSourcesExpanded = it },
@ -95,8 +97,15 @@ internal fun LazyListScope.homescreenSettingsContent(
message = "Install an addon with board-compatible catalogs to configure Homescreen rows.",
)
} else {
val catalogCount = items.count { !it.isCollection }
val collectionCount = items.count { it.isCollection }
val sectionTitle = when {
collectionCount > 0 && catalogCount > 0 -> "CATALOGS & COLLECTIONS"
collectionCount > 0 -> "COLLECTIONS"
else -> "CATALOGS"
}
SettingsSection(
title = "CATALOGS",
title = sectionTitle,
isTablet = isTablet,
actions = {
NuvioActionLabel(
@ -105,9 +114,21 @@ internal fun LazyListScope.homescreenSettingsContent(
)
},
) {
var showPinnedToast by remember { mutableStateOf(false) }
val hapticFeedback = LocalHapticFeedback.current
NuvioPinnedCollectionToast(
visible = showPinnedToast,
onDismiss = { showPinnedToast = false },
)
HomescreenCatalogList(
isTablet = isTablet,
items = items,
onPinnedDragAttempt = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
showPinnedToast = true
},
)
}
}
@ -219,6 +240,7 @@ private fun HomescreenSummaryCard(
private fun HomescreenCatalogList(
isTablet: Boolean,
items: List<HomeCatalogSettingsItem>,
onPinnedDragAttempt: () -> Unit,
) {
var expandedKey by remember { mutableStateOf<String?>(null) }
val hapticFeedback = LocalHapticFeedback.current
@ -226,6 +248,11 @@ private fun HomescreenCatalogList(
val reorderableLazyListState = rememberReorderableLazyListState(
lazyListState = lazyListState,
) { from, to ->
val fromItem = items.getOrNull(from.index)
val toItem = items.getOrNull(to.index)
if (fromItem?.isPinnedToTop == true || toItem?.isPinnedToTop == true) {
return@rememberReorderableLazyListState
}
HomeCatalogSettingsRepository.moveByIndex(from.index, to.index)
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
@ -238,7 +265,11 @@ private fun HomescreenCatalogList(
state = lazyListState,
) {
itemsIndexed(items, key = { _, item -> item.key }) { index, item ->
ReorderableItem(reorderableLazyListState, key = item.key) { isDragging ->
ReorderableItem(
reorderableLazyListState,
key = item.key,
enabled = !item.isPinnedToTop,
) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
@ -256,6 +287,7 @@ private fun HomescreenCatalogList(
onTitleChange = { HomeCatalogSettingsRepository.setCustomTitle(item.key, it) },
onEnabledChange = { HomeCatalogSettingsRepository.setEnabled(item.key, it) },
dragHandleScope = this@ReorderableItem,
onPinnedDragAttempt = onPinnedDragAttempt,
)
}
}

View file

@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -342,6 +343,7 @@ internal fun HomescreenCatalogRow(
onTitleChange: (String) -> Unit,
onEnabledChange: (Boolean) -> Unit,
dragHandleScope: ReorderableCollectionItemScope,
onPinnedDragAttempt: () -> Unit = {},
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 18.dp else 16.dp
@ -375,15 +377,19 @@ internal fun HomescreenCatalogRow(
overflow = TextOverflow.Ellipsis,
)
Text(
text = item.addonName,
text = if (item.isCollection) "Collection • ${item.addonName}" else item.addonName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = buildString {
append(if (item.enabled) "Visible" else "Hidden")
append("")
append(if (item.heroSourceEnabled) "Hero source" else "Not in hero")
if (item.isCollection) {
if (item.isPinnedToTop) append(" • Pinned to top")
} else {
append("")
append(if (item.heroSourceEnabled) "Hero source" else "Not in hero")
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -403,29 +409,41 @@ internal fun HomescreenCatalogRow(
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
),
)
IconButton(
modifier = with(dragHandleScope) {
Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
},
if (item.isPinnedToTop) {
IconButton(
onClick = onPinnedDragAttempt,
) {
Icon(
imageVector = Icons.Rounded.Lock,
contentDescription = "Pinned",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
},
onClick = {},
) {
Icon(
imageVector = Icons.Rounded.Menu,
contentDescription = "Reorder",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
IconButton(
modifier = with(dragHandleScope) {
Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
},
)
},
onClick = {},
) {
Icon(
imageVector = Icons.Rounded.Menu,
contentDescription = "Reorder",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
AnimatedVisibility(visible = expanded) {
AnimatedVisibility(visible = expanded && !item.isCollection) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {

View file

@ -14,6 +14,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
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.plugins.PluginRepository
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
@ -40,9 +41,11 @@ fun HomescreenSettingsScreen(
}
}
val homescreenSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
AddonRepository.initialize()
CollectionRepository.initialize()
}
LaunchedEffect(homescreenCatalogRefreshKey) {
@ -50,6 +53,10 @@ fun HomescreenSettingsScreen(
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons)
}
LaunchedEffect(collections) {
HomeCatalogSettingsRepository.syncCollections(collections)
}
NuvioScreen(
modifier = Modifier
.fillMaxSize()