From 5c87f821e0b5f83c9381c7b9283d7c1b2542131e Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 11 Mar 2026 16:30:00 -0400 Subject: [PATCH] feat: add drag-to-reorder for tabs within pinned/unpinned groups Made-with: Cursor --- .../screens/stationlist/StationListScreen.kt | 232 ++++++++++++++---- .../stationlist/StationListViewModel.kt | 8 + 2 files changed, 192 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt index 3d5fe48..bfec0ab 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt @@ -6,6 +6,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,6 +21,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -38,7 +41,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -49,9 +51,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -61,6 +69,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage +import xyz.cottongin.radio247.data.model.Playlist import xyz.cottongin.radio247.data.model.Station import xyz.cottongin.radio247.data.model.StationStream import xyz.cottongin.radio247.service.PlaybackState @@ -68,6 +77,13 @@ import xyz.cottongin.radio247.service.StreamResolver import org.json.JSONArray import xyz.cottongin.radio247.ui.components.MiniPlayer +private class TabDragState { + var isDragging by mutableStateOf(false) + var draggedIndex by mutableIntStateOf(-1) + var dragOffset by mutableFloatStateOf(0f) + var currentTabs by mutableStateOf>(emptyList()) +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun StationListScreen( @@ -163,54 +179,14 @@ fun StationListScreen( .padding(paddingValues) ) { if (viewState.tabs.size > 1) { - ScrollableTabRow( + DragReorderTabRow( + tabs = viewState.tabs, selectedTabIndex = viewState.selectedTabIndex, - edgePadding = 16.dp - ) { - viewState.tabs.forEachIndexed { index, tab -> - var showTabMenu by remember { mutableStateOf(false) } - Box { - Tab( - selected = viewState.selectedTabIndex == index, - onClick = { viewModel.selectTab(index) }, - text = { - Text( - text = tab.label, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - modifier = Modifier.combinedClickable( - onClick = { viewModel.selectTab(index) }, - onLongClick = { if (tab.playlist != null) showTabMenu = true } - ) - ) - DropdownMenu( - expanded = showTabMenu, - onDismissRequest = { showTabMenu = false } - ) { - if (tab.playlist != null && !tab.isBuiltIn) { - DropdownMenuItem( - text = { Text("Rename") }, - onClick = { - showTabMenu = false - tabToRename = tab - } - ) - } - if (tab.playlist != null) { - DropdownMenuItem( - text = { Text(if (tab.playlist.pinned) "Unpin" else "Pin") }, - onClick = { - showTabMenu = false - viewModel.togglePinned(tab.playlist) - } - ) - } - } - } - } - } + onSelectTab = { viewModel.selectTab(it) }, + onReorderTabs = { viewModel.reorderTabs(it) }, + onRenameRequested = { tabToRename = it }, + onTogglePinned = { viewModel.togglePinned(it) } + ) } SortChipRow( @@ -375,6 +351,166 @@ fun StationListScreen( } } +@Composable +private fun DragReorderTabRow( + tabs: List, + selectedTabIndex: Int, + onSelectTab: (Int) -> Unit, + onReorderTabs: (List) -> Unit, + onRenameRequested: (TabInfo) -> Unit, + onTogglePinned: (Playlist) -> Unit +) { + val dragState = remember { TabDragState() } + val scrollState = rememberScrollState() + val density = LocalDensity.current + val swapThresholdPx = with(density) { 48.dp.toPx() } + + LaunchedEffect(tabs) { + if (!dragState.isDragging) { + dragState.currentTabs = tabs + } + } + + val displayTabs = if (dragState.isDragging) dragState.currentTabs else tabs + val myStationsIndex = tabs.indexOfFirst { it.playlist == null } + val pinnedEnd = if (myStationsIndex >= 0) myStationsIndex else tabs.size + val unpinnedStart = if (myStationsIndex >= 0) myStationsIndex + 1 else tabs.size + + Box( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), + verticalAlignment = Alignment.CenterVertically + ) { + displayTabs.forEachIndexed { index, tab -> + key(tab.playlist?.id ?: -1L) { + val isDraggable = tab.playlist != null + var showTabMenu by remember(tab) { mutableStateOf(false) } + + val tabModifier = Modifier + .combinedClickable( + onClick = { onSelectTab(index) }, + onLongClick = { + if (isDraggable) showTabMenu = true + } + ) + .then( + if (dragState.isDragging && dragState.draggedIndex == index) { + Modifier.graphicsLayer { translationX = dragState.dragOffset } + } else { + Modifier + } + ) + .then( + if (isDraggable) { + Modifier.pointerInput(tabs) { + detectDragGesturesAfterLongPress( + onDragStart = { + dragState.isDragging = true + dragState.draggedIndex = index + dragState.dragOffset = 0f + dragState.currentTabs = ArrayList(tabs) + }, + onDrag = { change, dragAmount -> + change.consume() + val currentIndex = dragState.draggedIndex + val newOffset = dragState.dragOffset + dragAmount.x + val groupStart = if (currentIndex < pinnedEnd) 0 else unpinnedStart + val groupEnd = if (currentIndex < pinnedEnd) pinnedEnd else tabs.size + + if (newOffset > swapThresholdPx && currentIndex + 1 < groupEnd) { + val mutable = dragState.currentTabs.toMutableList() + val tmp = mutable[currentIndex] + mutable[currentIndex] = mutable[currentIndex + 1] + mutable[currentIndex + 1] = tmp + dragState.currentTabs = mutable + dragState.draggedIndex = currentIndex + 1 + dragState.dragOffset = 0f + } else if (newOffset < -swapThresholdPx && currentIndex - 1 >= groupStart) { + val mutable = dragState.currentTabs.toMutableList() + val tmp = mutable[currentIndex] + mutable[currentIndex] = mutable[currentIndex - 1] + mutable[currentIndex - 1] = tmp + dragState.currentTabs = mutable + dragState.draggedIndex = currentIndex - 1 + dragState.dragOffset = 0f + } else { + dragState.dragOffset = newOffset + } + }, + onDragEnd = { + val reordered = dragState.currentTabs + val playlists = if (dragState.draggedIndex < pinnedEnd) { + reordered.subList(0, pinnedEnd).mapNotNull { it.playlist } + } else { + reordered.subList(unpinnedStart, reordered.size).mapNotNull { it.playlist } + } + if (playlists.isNotEmpty()) { + onReorderTabs(playlists) + } + dragState.isDragging = false + dragState.draggedIndex = -1 + dragState.dragOffset = 0f + }, + onDragCancel = { + dragState.isDragging = false + dragState.draggedIndex = -1 + dragState.dragOffset = 0f + } + ) + } + } else { + Modifier + } + ) + + Box { + Tab( + selected = selectedTabIndex == index, + onClick = { onSelectTab(index) }, + text = { + Text( + text = tab.label, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + modifier = tabModifier + ) + DropdownMenu( + expanded = showTabMenu, + onDismissRequest = { showTabMenu = false } + ) { + if (tab.playlist != null && !tab.isBuiltIn) { + DropdownMenuItem( + text = { Text("Rename") }, + onClick = { + showTabMenu = false + onRenameRequested(tab) + } + ) + } + if (tab.playlist != null) { + DropdownMenuItem( + text = { Text(if (tab.playlist.pinned) "Unpin" else "Pin") }, + onClick = { + showTabMenu = false + onTogglePinned(tab.playlist) + } + ) + } + } + } + } + } + } + } +} + @Composable private fun SortChipRow( sortMode: SortMode, diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt index 10b0759..34331a6 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt @@ -353,4 +353,12 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat fun cancelImport() { _pendingImport.value = null } + + fun reorderTabs(reorderedPlaylists: List) { + viewModelScope.launch { + for ((index, playlist) in reorderedPlaylists.withIndex()) { + playlistDao.updateSortOrder(playlist.id, index) + } + } + } }