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.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -49,9 +51,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.data.model.StationStream import xyz.cottongin.radio247.data.model.StationStream
import xyz.cottongin.radio247.service.PlaybackState import xyz.cottongin.radio247.service.PlaybackState
@@ -68,6 +77,13 @@ import xyz.cottongin.radio247.service.StreamResolver
import org.json.JSONArray import org.json.JSONArray
import xyz.cottongin.radio247.ui.components.MiniPlayer 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) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun StationListScreen( fun StationListScreen(
@@ -163,54 +179,14 @@ fun StationListScreen(
.padding(paddingValues) .padding(paddingValues)
) { ) {
if (viewState.tabs.size > 1) { if (viewState.tabs.size > 1) {
ScrollableTabRow( DragReorderTabRow(
tabs = viewState.tabs,
selectedTabIndex = viewState.selectedTabIndex, selectedTabIndex = viewState.selectedTabIndex,
edgePadding = 16.dp onSelectTab = { viewModel.selectTab(it) },
) { onReorderTabs = { viewModel.reorderTabs(it) },
viewState.tabs.forEachIndexed { index, tab -> onRenameRequested = { tabToRename = it },
var showTabMenu by remember { mutableStateOf(false) } onTogglePinned = { viewModel.togglePinned(it) }
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)
}
)
}
}
}
}
}
} }
SortChipRow( 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 @Composable
private fun SortChipRow( private fun SortChipRow(
sortMode: SortMode, sortMode: SortMode,

View File

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