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.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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user