Added drag and drop to Android

This commit is contained in:
2025-12-08 12:55:55 -07:00
parent c836a18d7c
commit 5e815353cb
24 changed files with 241 additions and 19 deletions

View File

@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -13,8 +15,8 @@ android {
applicationId = "com.atridad.magiccounter"
minSdk = 31
targetSdk = 36
versionCode = 2
versionName = "1.2.0"
versionCode = 3
versionName = "1.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -34,9 +36,7 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
java {
toolchain {

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -15,6 +15,20 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.zIndex
import java.util.Collections
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.CancellationException
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
@@ -29,12 +43,17 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -79,6 +98,14 @@ fun GameScreen(
// State for stop game confirmation
val showStopConfirm = remember { mutableStateOf(false) }
// Local list for drag-and-drop reordering
val uiPlayers = remember { mutableStateListOf<PlayerState>() }
LaunchedEffect(state.players) {
uiPlayers.clear()
uiPlayers.addAll(state.players)
}
// Initialize
state.players.forEach { p ->
lifeTotals.putIfAbsent(p.id, p.life)
@@ -100,7 +127,7 @@ fun GameScreen(
}
fun snapshotState(): GameState = state.copy(
players = state.players.map { p ->
players = uiPlayers.map { p ->
PlayerState(
id = p.id,
name = p.name,
@@ -167,19 +194,75 @@ fun GameScreen(
gameLocked.value = true
}
val displayPlayers = state.players.sortedBy { eliminated[it.id] == true }
val listState = rememberLazyListState()
var draggingItemIndex by remember { mutableStateOf<Int?>(null) }
var draggingItemOffset by remember { mutableFloatStateOf(0f) }
val haptics = LocalHapticFeedback.current
LazyColumn(
state = listState,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(displayPlayers, key = { _, item -> item.id }) { index, player ->
itemsIndexed(uiPlayers, key = { _, item -> item.id }) { index, player ->
val accent = seatAccentColor(index, MaterialTheme.colorScheme)
val perPlayerCommander: Map<Int, Int> = commanderDamages[player.id]?.toMap() ?: emptyMap()
PlayerCard(
val isDragging = index == draggingItemIndex
val currentItemIndex by rememberUpdatedState(index)
Box(
modifier = Modifier
.then(if (isDragging) Modifier else Modifier.animateItem())
.zIndex(if (isDragging) 1f else 0f)
.graphicsLayer {
translationY = if (isDragging) draggingItemOffset else 0f
scaleX = if (isDragging) 1.05f else 1f
scaleY = if (isDragging) 1.05f else 1f
shadowElevation = if (isDragging) 8.dp.toPx() else 0f
}
.pointerInput(player.id) {
detectDragGesturesAfterShortPress(
onDragStart = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
draggingItemIndex = currentItemIndex
draggingItemOffset = 0f
},
onDrag = { change, dragAmount ->
change.consume()
draggingItemOffset += dragAmount.y
val currentIndex = currentItemIndex
val currentInfo = listState.layoutInfo.visibleItemsInfo.find { it.index == currentIndex }
if (currentInfo != null) {
val currentCenter = currentInfo.offset + currentInfo.size / 2 + draggingItemOffset
val targetItem = listState.layoutInfo.visibleItemsInfo.find {
it.index != currentIndex &&
currentCenter > it.offset &&
currentCenter < (it.offset + it.size)
}
if (targetItem != null) {
val targetIndex = targetItem.index
if (currentIndex < uiPlayers.size && targetIndex < uiPlayers.size) {
Collections.swap(uiPlayers, currentIndex, targetIndex)
draggingItemIndex = targetIndex
draggingItemOffset -= (targetItem.offset - currentInfo.offset)
}
}
}
},
onDragEnd = {
draggingItemIndex = null
draggingItemOffset = 0f
onProgress?.invoke(snapshotState())
}
)
}
) {
PlayerCard(
player = player,
gameState = state,
opponents = state.players.map { it.id }.filter { it != player.id },
life = lifeTotals[player.id] ?: state.startingLife,
onLifeChange = { new ->
@@ -218,6 +301,7 @@ fun GameScreen(
}
}
)
}
}
}
@@ -272,7 +356,6 @@ private fun seatAccentColor(index: Int, scheme: androidx.compose.material3.Color
@Composable
private fun PlayerCard(
player: PlayerState,
gameState: GameState,
opponents: List<Int>,
life: Int,
onLifeChange: (Int) -> Unit,
@@ -427,4 +510,67 @@ private fun CounterIconButton(icon: ImageVector, contentDescription: String, ena
}
}
suspend fun PointerInputScope.detectDragGesturesAfterShortPress(
onDragStart: (Offset) -> Unit = { },
onDragEnd: () -> Unit = { },
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) {
while (true) {
val down = awaitPointerEventScope {
awaitFirstDown(requireUnconsumed = false)
}
var dragStarted = false
try {
withTimeout(200) {
awaitPointerEventScope {
val downId = down.id
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == downId }
if (change == null || !change.pressed) {
throw CancellationException("Up")
}
val positionChange = change.position - down.position
if (positionChange.getDistance() > viewConfiguration.touchSlop) {
throw CancellationException("Moved")
}
}
}
}
} catch (_: TimeoutCancellationException) {
dragStarted = true
} catch (_: CancellationException) {
}
if (dragStarted) {
onDragStart(down.position)
try {
awaitPointerEventScope {
val downId = down.id
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == downId }
if (change == null || !change.pressed) {
break
}
val delta = change.positionChange()
if (delta != Offset.Zero) {
change.consume()
onDrag(change, delta)
}
}
}
} finally {
onDragEnd()
}
}
}
}

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@android:color/transparent" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@android:color/transparent" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>

View File

@@ -1,5 +1,5 @@
[versions]
agp = "8.12.1"
agp = "8.12.3"
kotlin = "2.0.21"
coreKtx = "1.10.1"
junit = "4.13.2"