feat: implement reorderable list for collections and folders

This commit is contained in:
tapframe 2026-04-07 17:48:47 +05:30
parent bd4f52bcfd
commit 78cc2abb5e
5 changed files with 239 additions and 100 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String, UIImage>()
private val gifImageCacheOrder = mutableListOf<String>()
private val gifImageInFlight = mutableMapOf<String, Deferred<UIImage?>>()
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<c
return runCatching {
val source = data?.let { CGImageSourceCreateWithData(it, null) } ?: return null
val count = CGImageSourceGetCount(source).toInt()
val frames = mutableListOf<UIImage>()
val frames = mutableListOf<GifFrame>()
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<c
if (frames.isEmpty()) return null
val durationSeconds = (count * 0.1).coerceAtLeast(0.1)
UIImage.animatedImageWithImages(frames, durationSeconds)
val animatedFrames = expandedGifFrames(frames)
val durationSeconds = animatedFrames.size * MinimumGifFrameDurationSeconds
UIImage.animatedImageWithImages(animatedFrames, durationSeconds)
}.getOrNull()
}
}
private fun gifFrameDurationSeconds(source: Any, index: Int): Double {
val properties = CGImageSourceCopyPropertiesAtIndex(source, index.toULong(), null) as? Map<Any?, *>
?: return DefaultGifFrameDurationSeconds
val gifProperties = properties[kCGImagePropertyGIFDictionary] as? Map<Any?, *>
?: 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<GifFrame>): List<UIImage> {
val expandedFrames = ArrayList<UIImage>(frames.size)
frames.forEach { frame ->
val repeatCount = (frame.durationSeconds / MinimumGifFrameDurationSeconds)
.roundToInt()
.coerceAtLeast(1)
repeat(repeatCount) {
expandedFrames.add(frame.image)
}
}
return expandedFrames
}
private fun Map<Any?, *>.doubleValue(key: Any?): Double? =
(this[key] as? Number)?.toDouble()