From 78cc2abb5e815ed3fd50ee24a8e1a48edb038cee Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:48:47 +0530 Subject: [PATCH] feat: implement reorderable list for collections and folders --- .../collection/CollectionEditorRepository.kt | 17 +-- .../collection/CollectionEditorScreen.kt | 120 ++++++++++++------ .../collection/CollectionManagementScreen.kt | 120 ++++++++++++------ .../collection/CollectionRepository.kt | 19 ++- .../CollectionCardRemoteImage.ios.kt | 63 ++++++++- 5 files changed, 239 insertions(+), 100 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index 0da4e0c2..b298b503 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -125,18 +125,19 @@ object CollectionEditorRepository { } 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) + moveFolderByIndex(index, index - 1) } fun moveFolderDown(index: Int) { + moveFolderByIndex(index, index + 1) + } + + fun moveFolderByIndex(fromIndex: Int, toIndex: Int) { + if (fromIndex == toIndex) return val list = _uiState.value.folders.toMutableList() - if (index < 0 || index >= list.size - 1) return - val item = list.removeAt(index) - list.add(index + 1, item) + if (fromIndex !in list.indices || toIndex !in list.indices) return + val item = list.removeAt(fromIndex) + list.add(toIndex, item) _uiState.value = _uiState.value.copy(folders = list) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index ab98406f..a3c03f7f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -13,20 +13,21 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons 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.material.icons.rounded.Menu import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip @@ -46,11 +47,13 @@ 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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.animation.core.animateDpAsState import com.nuvio.app.core.ui.NuvioInputField import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioScreen @@ -58,6 +61,9 @@ import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.features.home.PosterShape +import sh.calvin.reorderable.ReorderableCollectionItemScope +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -289,19 +295,14 @@ fun CollectionEditorScreen( } // 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.isNotEmpty()) { + item { + FolderReorderableList( + folders = state.folders, + onEdit = { CollectionEditorRepository.editFolder(it) }, + onDelete = { CollectionEditorRepository.removeFolder(it) }, + ) + } } if (state.folders.isEmpty()) { @@ -340,16 +341,58 @@ fun CollectionEditorScreen( } } +@Composable +private fun FolderReorderableList( + folders: List, + onEdit: (String) -> Unit, + onDelete: (String) -> Unit, +) { + val hapticFeedback = LocalHapticFeedback.current + val lazyListState = rememberLazyListState() + val reorderableLazyListState = rememberReorderableLazyListState( + lazyListState = lazyListState, + ) { from, to -> + CollectionEditorRepository.moveFolderByIndex(from.index, to.index) + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 720.dp), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + itemsIndexed(folders, key = { _, folder -> folder.id }) { _, folder -> + ReorderableItem(reorderableLazyListState, key = folder.id) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + + Surface( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.extraLarge, + shadowElevation = elevation, + ) { + FolderListItem( + folder = folder, + onEdit = { onEdit(folder.id) }, + onDelete = { onDelete(folder.id) }, + dragHandleScope = this@ReorderableItem, + ) + } + } + } + } +} + @Composable private fun FolderListItem( folder: CollectionFolder, - index: Int, - totalCount: Int, onEdit: () -> Unit, onDelete: () -> Unit, - onMoveUp: () -> Unit, - onMoveDown: () -> Unit, + dragHandleScope: ReorderableCollectionItemScope, ) { + val hapticFeedback = LocalHapticFeedback.current + NuvioSurfaceCard { Row( modifier = Modifier.fillMaxWidth(), @@ -385,27 +428,28 @@ private fun FolderListItem( } } Spacer(modifier = Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { IconButton( - onClick = onMoveUp, - enabled = index > 0, - modifier = Modifier.size(36.dp), + modifier = with(dragHandleScope) { + Modifier.draggableHandle( + onDragStarted = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDragStopped = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + ).size(36.dp) + }, + onClick = {}, ) { Icon( - imageVector = Icons.Rounded.ArrowUpward, - contentDescription = "Move up", - modifier = Modifier.size(20.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(20.dp).alpha(if (index < totalCount - 1) 1f else 0.3f), + imageVector = Icons.Rounded.Menu, + contentDescription = "Reorder", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(modifier = Modifier.weight(1f)) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt index a612c92b..d1649ba0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt @@ -7,22 +7,23 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState 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.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.material.icons.rounded.Menu import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -31,27 +32,32 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.collectAsState 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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback 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 +import androidx.compose.animation.core.animateDpAsState import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard +import sh.calvin.reorderable.ReorderableCollectionItemScope +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -113,19 +119,14 @@ fun CollectionManagementScreen( item { NuvioSectionLabel(text = "YOUR COLLECTIONS") } } - 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.isNotEmpty()) { + item { + CollectionReorderableList( + collections = collections, + onEdit = { onNavigateToEditor(it) }, + onDelete = { showDeleteConfirm = it }, + ) + } } if (collections.isEmpty()) { @@ -201,16 +202,58 @@ fun CollectionManagementScreen( ) } +@Composable +private fun CollectionReorderableList( + collections: List, + onEdit: (String) -> Unit, + onDelete: (String) -> Unit, +) { + val hapticFeedback = LocalHapticFeedback.current + val lazyListState = rememberLazyListState() + val reorderableLazyListState = rememberReorderableLazyListState( + lazyListState = lazyListState, + ) { from, to -> + CollectionRepository.moveByIndex(from.index, to.index) + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 720.dp), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + itemsIndexed(collections, key = { _, collection -> collection.id }) { _, collection -> + ReorderableItem(reorderableLazyListState, key = collection.id) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + + Surface( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.extraLarge, + shadowElevation = elevation, + ) { + CollectionListItem( + collection = collection, + onEdit = { onEdit(collection.id) }, + onDelete = { onDelete(collection.id) }, + dragHandleScope = this@ReorderableItem, + ) + } + } + } + } +} + @Composable private fun CollectionListItem( collection: Collection, - index: Int, - totalCount: Int, onEdit: () -> Unit, onDelete: () -> Unit, - onMoveUp: () -> Unit, - onMoveDown: () -> Unit, + dragHandleScope: ReorderableCollectionItemScope, ) { + val hapticFeedback = LocalHapticFeedback.current + NuvioSurfaceCard { Row( modifier = Modifier.fillMaxWidth(), @@ -238,27 +281,26 @@ private fun CollectionListItem( Spacer(modifier = Modifier.height(10.dp)) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, ) { IconButton( - onClick = onMoveUp, - enabled = index > 0, - modifier = Modifier.size(36.dp), + modifier = with(dragHandleScope) { + Modifier.draggableHandle( + onDragStarted = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDragStopped = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + ).size(36.dp) + }, + onClick = {}, ) { Icon( - imageVector = Icons.Rounded.ArrowUpward, - contentDescription = "Move up", - modifier = Modifier.size(20.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(20.dp).alpha(if (index < totalCount - 1) 1f else 0.3f), + imageVector = Icons.Rounded.Menu, + contentDescription = "Reorder", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(modifier = Modifier.weight(1f)) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index b0da6f13..f0c214ac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -76,21 +76,20 @@ object CollectionRepository { } 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() + moveByIndex(index, index - 1) } fun moveDown(index: Int) { + moveByIndex(index, index + 1) + } + + fun moveByIndex(fromIndex: Int, toIndex: 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) + if (fromIndex == toIndex) return + if (fromIndex !in list.indices || toIndex !in list.indices) return + val item = list.removeAt(fromIndex) + list.add(toIndex, item) _collections.value = list persist() } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt index 368bc8b2..60095686 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt @@ -24,22 +24,34 @@ import kotlinx.cinterop.addressOf import kotlinx.cinterop.reinterpret import platform.CoreGraphics.CGImageRef import platform.CoreFoundation.CFDataCreate +import platform.ImageIO.CGImageSourceCopyPropertiesAtIndex import platform.ImageIO.CGImageSourceCreateImageAtIndex import platform.ImageIO.CGImageSourceCreateWithData import platform.ImageIO.CGImageSourceGetCount +import platform.ImageIO.kCGImagePropertyGIFDelayTime +import platform.ImageIO.kCGImagePropertyGIFDictionary +import platform.ImageIO.kCGImagePropertyGIFUnclampedDelayTime import platform.UIKit.UIImage import platform.UIKit.UIImageView import platform.UIKit.UIViewContentMode import platform.CoreGraphics.CGImageRelease import kotlinx.cinterop.usePinned +import kotlin.math.roundToInt private val gifHttpClient = HttpClient(Darwin) private val gifDecodeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private const val MaxCachedGifImages = 12 +private const val DefaultGifFrameDurationSeconds = 0.1 +private const val MinimumGifFrameDurationSeconds = 0.02 private val gifImageCache = mutableMapOf() private val gifImageCacheOrder = mutableListOf() private val gifImageInFlight = mutableMapOf>() +private data class GifFrame( + val image: UIImage, + val durationSeconds: Double, +) + @OptIn(ExperimentalForeignApi::class) @Composable internal actual fun CollectionCardRemoteImage( @@ -147,12 +159,17 @@ private fun UIImage.Companion.gifImageWithData(data: kotlinx.cinterop.CPointer() + val frames = mutableListOf() for (index in 0 until count) { val imageRef: CGImageRef = CGImageSourceCreateImageAtIndex(source, index.toULong(), null) ?: continue try { - frames.add(UIImage.imageWithCGImage(imageRef)) + frames.add( + GifFrame( + image = UIImage.imageWithCGImage(imageRef), + durationSeconds = gifFrameDurationSeconds(source, index), + ) + ) } finally { CGImageRelease(imageRef) } @@ -160,7 +177,43 @@ private fun UIImage.Companion.gifImageWithData(data: kotlinx.cinterop.CPointer + ?: return DefaultGifFrameDurationSeconds + val gifProperties = properties[kCGImagePropertyGIFDictionary] as? Map + ?: return DefaultGifFrameDurationSeconds + + val unclampedDelay = gifProperties.doubleValue(kCGImagePropertyGIFUnclampedDelayTime) + if (unclampedDelay != null && unclampedDelay >= MinimumGifFrameDurationSeconds) { + return unclampedDelay + } + + val delay = gifProperties.doubleValue(kCGImagePropertyGIFDelayTime) + return when { + delay == null -> DefaultGifFrameDurationSeconds + delay < MinimumGifFrameDurationSeconds -> MinimumGifFrameDurationSeconds + else -> delay + } +} + +private fun expandedGifFrames(frames: List): List { + val expandedFrames = ArrayList(frames.size) + frames.forEach { frame -> + val repeatCount = (frame.durationSeconds / MinimumGifFrameDurationSeconds) + .roundToInt() + .coerceAtLeast(1) + repeat(repeatCount) { + expandedFrames.add(frame.image) + } + } + return expandedFrames +} + +private fun Map.doubleValue(key: Any?): Double? = + (this[key] as? Number)?.toDouble() \ No newline at end of file