Skip to content

Commit

Permalink
<wip> UI additions to allow waypoints to be added to routes and reord…
Browse files Browse the repository at this point in the history
…ered

This change allows the selection of Markers to be added to Routes. It's
not pretty from a UI point of view, but I think the code is at least
reasonably structured.
ReorderableLocationList adds a list of LocationItems which can be
dragged to reorder. Currently the whole item can be clicked to drag, but
really we just want a drag handle target on the end.
  • Loading branch information
davecraig committed Feb 8, 2025
1 parent 4398140 commit ed2a588
Show file tree
Hide file tree
Showing 19 changed files with 864 additions and 180 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package org.scottishtecharmy.soundscape.components

import android.annotation.SuppressLint
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.unit.IntOffset
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import java.lang.IndexOutOfBoundsException
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.sign

// This code is based on the excellent drag/drop example here:
//
// https://github.com/PSPDFKit-labs/Drag-to-Reorder-in-Compose/tree/main
//
@SuppressLint("ModifierFactoryUnreferencedReceiver")
fun <T> Modifier.dragToReorder(
item: T,
itemList: List<T>,
itemHeights: IntArray,
updateSlideState: (item: T, slideState: SlideState) -> Unit,
onStartDrag: (currIndex: Int) -> Unit = {},
onStopDrag: (currIndex: Int, destIndex: Int) -> Unit // Call invoked when drag is finished
): Modifier = composed {

// Keep track of the of the vertical drag offset smoothly
val offsetY = remember { Animatable(0f) }

val itemIndex = itemList.indexOf(item)
// Threshold for when an item should be considered as moved to a new position in the list
// Needs to be at least a half of the height of the item but this can be modified as needed
var numberOfSlidItems = 0
var previousNumberOfItems: Int
var listOffset = 0

val onDragStart = {
// Interrupt any ongoing animation of other items.
CoroutineScope(Job()).launch {
offsetY.stop()
}
onStartDrag(itemIndex)
}
val onDragging = { change: androidx.compose.ui.input.pointer.PointerInputChange ->

val verticalDragOffset = offsetY.value + change.positionChange().y
CoroutineScope(Job()).launch {
offsetY.snapTo(verticalDragOffset)
val offsetSign = offsetY.value.sign.toInt()

previousNumberOfItems = numberOfSlidItems
numberOfSlidItems = calculateNumberOfSlidItems(
offsetY.value,
itemIndex,
itemHeights
)

if (previousNumberOfItems > numberOfSlidItems) {
//println("Update ${itemIndex + previousNumberOfItems * offsetSign} to NONE ($previousNumberOfItems vs $numberOfSlidItems, ${offsetY.value * offsetSign})")
updateSlideState(
itemList[itemIndex + previousNumberOfItems * offsetSign],
SlideState.NONE
)
} else if ((numberOfSlidItems != 0) && (previousNumberOfItems != numberOfSlidItems)) {
try {
//println("Update ${itemIndex + numberOfSlidItems * offsetSign} to ${if (offsetSign == 1) "UP" else "DOWN"} ($previousNumberOfItems vs $numberOfSlidItems, ${offsetY.value * offsetSign})")
updateSlideState(
itemList[itemIndex + numberOfSlidItems * offsetSign],
if (offsetSign == 1) SlideState.UP else SlideState.DOWN
)
} catch (e: IndexOutOfBoundsException) {
println("Exception: $e")
numberOfSlidItems = previousNumberOfItems
}
}
listOffset = numberOfSlidItems * offsetSign
}
// Consume the gesture event, not passed to external
change.consume()
}
val onDragEnd = {
CoroutineScope(Job()).launch {
if(listOffset == 0) {
// If we haven't moved the item, then we want to snap back to our starting position.
// The reordering caused by onStopDrag will update item locations when the positions
// do change.
offsetY.snapTo(0.0F)
}
onStopDrag(itemIndex, itemIndex + listOffset)
}
}
pointerInput(Unit) {
coroutineScope {
detectDragGestures(
onDragStart = { onDragStart() },
onDrag = { change, _ -> onDragging(change) },
onDragEnd = { onDragEnd() },
onDragCancel = { onDragEnd() }
)
}
}.offset {
IntOffset(0, offsetY.value.roundToInt())
}
}

enum class SlideState { NONE, UP, DOWN }

fun calculateNumberOfSlidItems(
offsetY: Float,
itemIndex: Int,
itemHeights: IntArray,
): Int {

var offset = abs(offsetY)
val down = offsetY.sign.toInt()
var index = itemIndex
var count = 0

// Calculate how many items we've moved by. By making the cutoff point two-thirds of the item
// height this provides us with some hysteresis in the calculation.
while (offset > (2 * itemHeights[index]) / 3) {
offset -= itemHeights[index]
++count
index += down
if ((index < 0) || (index == itemHeights.size)) break
}

return count
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
Expand All @@ -29,56 +29,84 @@ import org.scottishtecharmy.soundscape.ui.theme.Foreground2
import org.scottishtecharmy.soundscape.ui.theme.IntroductionTheme
import org.scottishtecharmy.soundscape.ui.theme.PaleBlue

data class EnabledFunction(
var enabled: Boolean = false,
var functionString: (String) -> Unit = {},
var functionBoolean: (Boolean) -> Unit = {},
var value: Boolean = false,
)
data class LocationItemDecoration(
val location: Boolean = false,
val editRoute: EnabledFunction = EnabledFunction(),
val details: EnabledFunction = EnabledFunction()
)

@Composable
fun LocationItem(
item: LocationDescription,
onClick: () -> Unit,
modifier: Modifier = Modifier,
decoration: LocationItemDecoration = LocationItemDecoration(),
) {
Button(
onClick = onClick,
shape = RoundedCornerShape(0),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiary,
),
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if(decoration.location) {
Icon(
Icons.Rounded.LocationOn,
contentDescription = null,
tint = Color.White,
)
Column(
modifier = Modifier.padding(start = 18.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
}
Column(
modifier = Modifier.padding(start = 18.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
item.addressName?.let {
Text(
text = it,
fontWeight = FontWeight(700),
fontSize = 22.sp,
color = Color.White,
)
}
item.distance?.let {
Text(
text = it,
color = Foreground2,
fontWeight = FontWeight(450),
fontSize = 12.sp,
)
}
item.fullAddress?.let {
Text(
text = it,
fontWeight = FontWeight(400),
fontSize = 18.sp,
color = PaleBlue,
)
}
}
if(decoration.editRoute.enabled) {
Switch(
checked = decoration.editRoute.value,
onCheckedChange = decoration.editRoute.functionBoolean,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.Green,
uncheckedThumbColor = Color.Red,
)
)
} else if(decoration.details.enabled) {
Button(
onClick = {
decoration.details.functionString(item.addressName!!)
},
) {
item.addressName?.let {
Text(
text = it,
fontWeight = FontWeight(700),
fontSize = 22.sp,
color = Color.White,
)
}
item.distance?.let {
Text(
text = it,
color = Foreground2,
fontWeight = FontWeight(450),
)
}
item.fullAddress?.let {
Text(
text = it,
fontWeight = FontWeight(400),
fontSize = 18.sp,
color = PaleBlue,
)
}
Icon(
Icons.Rounded.ChevronRight,
contentDescription = null,
tint = Color.White,
)
}
}
}
Expand All @@ -105,8 +133,43 @@ fun PreviewSearchItemButton() {
)
LocationItem(
item = test,
onClick = {},
Modifier.width(200.dp),
decoration = LocationItemDecoration(
location = true,
editRoute = EnabledFunction(true, {}, {}, true),
details = EnabledFunction(false),
),
modifier = Modifier.width(200.dp),
)
}
}
}

@Preview(name = "Compact")
@Composable
fun PreviewCompactSearchItemButton() {
IntroductionTheme {
Column(
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
val test =
LocationDescription(
addressName = "Bristol",
distance = "17 Km",
location = LngLatAlt(8.00, 9.55)
)
LocationItem(
item = test,
decoration = LocationItemDecoration(
location = true,
editRoute = EnabledFunction(false),
details = EnabledFunction(true),
),
modifier = Modifier.width(200.dp),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,16 @@ fun MainSearchBar(
Column {
LocationItem(
item = item,
onClick = {
onItemClick(item)
onToggleSearch()
},
decoration = LocationItemDecoration(
location = true,
details = EnabledFunction(
true,
{
onItemClick(item)
onToggleSearch()
}
)
),
modifier =
Modifier.semantics {
this.collectionItemInfo =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package org.scottishtecharmy.soundscape.database.local.model

import io.realm.kotlin.ext.backlinks
import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.query.RealmResults
import io.realm.kotlin.types.EmbeddedRealmObject
import io.realm.kotlin.types.RealmList
import io.realm.kotlin.types.RealmObject
Expand All @@ -18,6 +16,12 @@ class Location : EmbeddedRealmObject {
add(latitude)
}
}
constructor(location: LngLatAlt) {
coordinates.apply {
add(location.longitude)
add(location.latitude)
}
}

// Empty constructor required by Realm
constructor() : this(NaN, NaN)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,10 @@ fun HomeScreen(
}

composable(HomeRoutes.AddRoute.route) {
AddRouteScreenVM(navController = navController)
AddRouteScreenVM(
navController = navController,
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing)
)
}

composable(HomeRoutes.RouteDetails.route + "/{routeName}") { backStackEntry ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ data class LocationDescription(
val country: String? = null,
var distance: String? = null,
var location: LngLatAlt = LngLatAlt(),
val marker: Boolean = false
val marker: Boolean = false,
var inRoute: Boolean = false
)
Loading

0 comments on commit ed2a588

Please sign in to comment.