feat: add drag-to-reorder for tabs within pinned/unpinned groups
Made-with: Cursor
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user