feat: add drag-to-reorder for tabs within pinned/unpinned groups

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-11 16:30:00 -04:00
parent 2d3b0cea7a
commit 5c87f821e0
2 changed files with 192 additions and 48 deletions

View File

@@ -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<List<TabInfo>>(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<TabInfo>,
selectedTabIndex: Int,
onSelectTab: (Int) -> Unit,
onReorderTabs: (List<Playlist>) -> 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,

View File

@@ -353,4 +353,12 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
fun cancelImport() {
_pendingImport.value = null
}
fun reorderTabs(reorderedPlaylists: List<Playlist>) {
viewModelScope.launch {
for ((index, playlist) in reorderedPlaylists.withIndex()) {
playlistDao.updateSortOrder(playlist.id, index)
}
}
}
}