Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save yasincidem/e96319adbc43d970ce2482bacc2335a3 to your computer and use it in GitHub Desktop.

Select an option

Save yasincidem/e96319adbc43d970ce2482bacc2335a3 to your computer and use it in GitHub Desktop.
.
package com.astralloop.monologue.ui.builder
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Air
import androidx.compose.material.icons.filled.CenterFocusStrong
import androidx.compose.material.icons.filled.DashboardCustomize
import androidx.compose.material.icons.filled.DragIndicator
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.filled.RemoveCircleOutline
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.astralloop.monologue.data.CustomItem
import com.astralloop.monologue.ui.theme.QuestionColors
import com.astralloop.monologue.ui.theme.MonologueDanger
import com.astralloop.monologue.ui.util.isLandscape
import com.astralloop.monologue.util.formatString
import com.astralloop.monologue.utils.LocalNavigator
import com.astralloop.monologue.utils.HapticEvent
import com.astralloop.monologue.utils.rememberAppHaptics
import monologue.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
private data class BuilderEntry(
val id: Long,
val item: CustomItem,
val isRemoving: Boolean = false
)
private const val BuilderMaxSteps = 10
private val BuilderQuestionBase = Color(0xFF8FB7AD)
private val BuilderBreathingBase = Color(0xFFB9C7D8)
private val BuilderAwarenessBase = Color(0xFFD3C0A1)
private val BuilderSilenceBase = Color(0xFFBFAFCF)
private fun tonedTypeColor(base: Color, colors: QuestionColors): Color {
val isDark = colors.isDarkMode
val target = if (isDark) Color.White else Color.Black
return lerp(base, target, if (isDark) 0.12f else 0.08f)
}
private fun baseTypeColor(item: CustomItem): Color = when (item) {
is CustomItem.Question -> BuilderQuestionBase
is CustomItem.Breathing -> BuilderBreathingBase
is CustomItem.Awareness -> BuilderAwarenessBase
is CustomItem.Silence -> BuilderSilenceBase
}
@Composable
fun MonologueBuilderScreen(
colors: QuestionColors,
onSave: (String, List<CustomItem>) -> Unit,
onBack: () -> Unit
) {
val navigator = LocalNavigator.current
var title by remember { mutableStateOf("") }
val entries = remember { mutableStateListOf<BuilderEntry>() }
var nextId by remember { mutableLongStateOf(0L) }
var lastToastTimeMs by remember { mutableLongStateOf(0L) }
val listState = rememberLazyListState()
val density = LocalDensity.current
val edgeThresholdPx = with(density) { 88.dp.toPx() }
val maxAutoScrollPx = with(density) { 20.dp.toPx() }
val swapHysteresisPx = with(density) { 10.dp.toPx() }
var draggingItemId by remember { mutableStateOf<Long?>(null) }
var draggingDelta by remember { mutableFloatStateOf(0f) }
var draggingItemHeight by remember { mutableFloatStateOf(0f) }
var autoScrollSpeed by remember { mutableFloatStateOf(0f) }
val spacingPx = with(density) { 14.dp.toPx() }
val haptic = rememberAppHaptics()
// After drop: the item that is sliding into its final resting place
var settlingItemId by remember { mutableStateOf<Long?>(null) }
var settlingDelta by remember { mutableFloatStateOf(0f) }
val scope = rememberCoroutineScope()
val canSave = title.isNotBlank() && entries.isNotEmpty()
val isDarkMode = colors.isDarkMode
val highlightBase = if (isDarkMode) colors.glowColor else colors.accent
val premiumHighlight = highlightBase.copy(alpha = if (isDarkMode) 0.75f else 0.6f)
val premiumOnHighlight =
if (premiumHighlight.luminance() > 0.5f) colors.background else colors.text
val maxStepsMessage = formatString(
stringResource(Res.string.builder_max_steps),
BuilderMaxSteps
)
fun addEntry(item: CustomItem) {
if (entries.size >= BuilderMaxSteps) {
val now = kotlin.time.Clock.System.now().toEpochMilliseconds()
if (now - lastToastTimeMs > 800L) {
navigator.showToast(maxStepsMessage)
lastToastTimeMs = now
}
haptic.perform(HapticEvent.Warning)
return
}
entries.add(BuilderEntry(id = nextId++, item = item))
}
LaunchedEffect(Unit) {
if (entries.isEmpty()) {
addEntry(CustomItem.Question(""))
}
}
val currentTargetIndex by remember {
derivedStateOf {
val dragId = draggingItemId ?: return@derivedStateOf null
val draggedIndex = entries.indexOfFirst { it.id == dragId }
if (draggedIndex == -1) return@derivedStateOf null
val info = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == dragId }
?: return@derivedStateOf draggedIndex
// Viewport-relative center of the dragging item
val center = info.offset + info.size / 2f + draggingDelta
var bestIndex = draggedIndex
var minDiff = Float.MAX_VALUE
listState.layoutInfo.visibleItemsInfo.forEach { itemInfo ->
if (itemInfo.key is Long) {
val itemIndex = entries.indexOfFirst { it.id == itemInfo.key }
if (itemIndex != -1) {
val itemCenter = itemInfo.offset + itemInfo.size / 2f
val diff = kotlin.math.abs(center - itemCenter)
// Give slight preference to current target to avoid flickering
if (diff < minDiff) {
minDiff = diff
bestIndex = itemIndex
}
}
}
}
bestIndex
}
}
fun updateAutoScrollSpeed() {
val dragId = draggingItemId ?: return
val info = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == dragId } ?: return
val visualTop = info.offset + draggingDelta
val visualBottom = visualTop + info.size
val viewportStart = listState.layoutInfo.viewportStartOffset.toFloat()
val viewportEnd = listState.layoutInfo.viewportEndOffset.toFloat()
val topZoneEnd = viewportStart + edgeThresholdPx
val bottomZoneStart = viewportEnd - edgeThresholdPx
autoScrollSpeed = when {
visualTop < topZoneEnd -> {
val ratio = ((topZoneEnd - visualTop) / edgeThresholdPx).coerceIn(0f, 1f)
-maxAutoScrollPx * ratio
}
visualBottom > bottomZoneStart -> {
val ratio = ((visualBottom - bottomZoneStart) / edgeThresholdPx).coerceIn(0f, 1f)
maxAutoScrollPx * ratio
}
else -> 0f
}
}
fun onDragDelta(delta: Float) {
draggingDelta += delta
updateAutoScrollSpeed()
}
fun onDragEnd() {
val dragId = draggingItemId ?: return
val target = currentTargetIndex ?: return
val fromIndex = entries.indexOfFirst { it.id == dragId }
autoScrollSpeed = 0f
if (fromIndex == -1) return
haptic.perform(HapticEvent.Collapse)
if (fromIndex == target) {
// No reorder — animate back to rest
settlingItemId = dragId
settlingDelta = draggingDelta
draggingItemId = null
draggingDelta = 0f
scope.launch {
animate(
initialValue = settlingDelta,
targetValue = 0f,
animationSpec = tween(180)
) { v: Float, _: Float -> settlingDelta = v }
settlingDelta = 0f
settlingItemId = null
}
return
}
// Pre-calculate expected pixel shift so we can set settlingDelta immediately
// without waiting a frame (which would cause jitter)
var expectedShift = 0f
if (fromIndex < target) {
for (i in (fromIndex + 1)..target) {
val info =
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == entries[i].id }
expectedShift += (info?.size?.toFloat()
?: (draggingItemHeight - spacingPx)) + spacingPx
}
} else {
for (i in target until fromIndex) {
val info =
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == entries[i].id }
expectedShift -= (info?.size?.toFloat()
?: (draggingItemHeight - spacingPx)) + spacingPx
}
}
// Set settling delta BEFORE clearing drag state — ensures zero visual discontinuity
val initialDelta = draggingDelta - expectedShift
settlingItemId = dragId
settlingDelta = initialDelta
// Reorder and clear drag state
val moved = entries.removeAt(fromIndex)
entries.add(target, moved)
draggingItemId = null
draggingDelta = 0f
// Animate the settling item smoothly to its resting position
scope.launch {
animate(
initialValue = initialDelta,
targetValue = 0f,
animationSpec = tween(220)
) { v: Float, _: Float -> settlingDelta = v }
settlingDelta = 0f
settlingItemId = null
}
}
LaunchedEffect(draggingItemId) {
while (draggingItemId != null) {
if (autoScrollSpeed != 0f) {
val consumed = listState.scrollBy(autoScrollSpeed)
draggingDelta += consumed
updateAutoScrollSpeed()
}
withFrameNanos { }
}
}
val isLandscape = isLandscape()
Scaffold(
modifier = Modifier.windowInsetsPadding(WindowInsets.displayCutout)
.windowInsetsPadding(WindowInsets.statusBars),
containerColor = Color.Transparent,
topBar = {
val backLabel = stringResource(Res.string.back_label)
val builderTitle = stringResource(Res.string.builder_title)
val saveLabel = stringResource(Res.string.builder_save)
val saveBgColor by animateColorAsState(
targetValue = if (canSave) premiumHighlight else colors.text.copy(alpha = 0.06f),
animationSpec = tween(220),
label = "SaveBg"
)
val saveContentColor by animateColorAsState(
targetValue = if (canSave) premiumOnHighlight else colors.text.copy(alpha = 0.4f),
animationSpec = tween(220),
label = "SaveContent"
)
val headerHeight = if (isLandscape) 56.dp else 72.dp
val backSize = if (isLandscape) 36.dp else 40.dp
val backIconSize = if (isLandscape) 18.dp else 20.dp
val titleSize = if (isLandscape) 14.sp else 16.sp
Box(
modifier = Modifier
.fillMaxWidth()
.height(headerHeight)
.padding(horizontal = 16.dp)
) {
IconButton(
onClick = onBack,
modifier = Modifier
.align(Alignment.CenterStart)
.size(backSize)
.background(colors.text.copy(alpha = 0.05f), CircleShape)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = backLabel,
tint = colors.text.copy(alpha = 0.6f),
modifier = Modifier.size(backIconSize)
)
}
Text(
builderTitle,
style = TextStyle(
color = colors.text,
fontWeight = FontWeight.Black,
fontSize = titleSize,
letterSpacing = 2.sp
),
modifier = Modifier
.align(Alignment.Center)
)
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.clip(RoundedCornerShape(18.dp))
.background(saveBgColor)
.border(
width = 1.dp,
color = if (canSave) premiumHighlight.copy(alpha = 0.7f) else colors.text.copy(
alpha = 0.12f
),
shape = RoundedCornerShape(18.dp)
)
.clickable(enabled = canSave) {
onSave(title.trim(), entries.map { it.item })
}
.padding(horizontal = 14.dp, vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (canSave) Icons.Default.Done else Icons.Default.DashboardCustomize,
null,
tint = saveContentColor,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
saveLabel,
color = saveContentColor,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 1.1.sp
)
}
}
}
}
) { paddingValues ->
if (isLandscape) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Left Panel: Header & Controls
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 20.dp)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(16.dp))
HeaderSection(
title = title,
onTitleChange = { title = it },
stepCount = entries.count { !it.isRemoving },
colors = colors,
highlightColor = premiumHighlight
)
Spacer(modifier = Modifier.height(20.dp))
AddStepSection(
entriesSize = entries.size,
onAdd = { addEntry(it) },
colors = colors,
isLandscape = true
)
Spacer(modifier = Modifier.height(20.dp))
}
// Right Panel: The List
Box(
modifier = Modifier
.weight(1.2f)
.fillMaxHeight()
) {
SequenceList(
listState = listState,
entries = entries,
draggingItemId = draggingItemId,
settlingItemId = settlingItemId,
currentTargetIndex = currentTargetIndex,
draggingDelta = draggingDelta,
settlingDelta = settlingDelta,
draggingItemHeight = draggingItemHeight,
colors = colors,
onUpdateQuestion = { index, text ->
entries[index] = entries[index].copy(item = CustomItem.Question(text))
},
onDelete = { entry ->
scope.launch {
val idx = entries.indexOfFirst { it.id == entry.id }
if (idx != -1) {
entries[idx] = entry.copy(isRemoving = true)
delay(280)
entries.removeAll { it.id == entry.id }
}
}
},
onDragStart = { entry, info ->
if (!entry.isRemoving) {
settlingItemId = null
settlingDelta = 0f
draggingItemId = entry.id
draggingItemHeight = info.size.toFloat() + spacingPx
draggingDelta = 0f
updateAutoScrollSpeed()
haptic.perform(HapticEvent.Expand)
}
},
onDrag = { delta -> onDragDelta(delta) },
onDragEnd = { onDragEnd() }
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item {
HeaderSection(
title = title,
onTitleChange = { title = it },
stepCount = entries.count { !it.isRemoving },
colors = colors,
highlightColor = premiumHighlight,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
item {
AddStepSection(
entriesSize = entries.size,
onAdd = { addEntry(it) },
colors = colors,
isLandscape = false
)
}
item {
Text(
text = stringResource(Res.string.builder_drag_hint),
modifier = Modifier.padding(horizontal = 20.dp),
color = colors.text.copy(alpha = 0.48f),
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp,
letterSpacing = 0.5.sp
)
}
itemsIndexed(
items = entries,
key = { _, entry -> entry.id }
) { index, entry ->
val isDragging = draggingItemId == entry.id
val isSettlingThis = settlingItemId == entry.id
val draggedIndex =
draggingItemId?.let { id -> entries.indexOfFirst { it.id == id } } ?: -1
val target = currentTargetIndex ?: draggedIndex
val visualIndex = when {
isDragging -> target
draggedIndex != -1 && draggedIndex < target && index in (draggedIndex + 1)..target -> index - 1
draggedIndex != -1 && draggedIndex > target && index in target until draggedIndex -> index + 1
else -> index
}
val targetShift = when {
isDragging -> 0f
draggedIndex == -1 -> 0f
draggedIndex < target && index in (draggedIndex + 1)..target -> -draggingItemHeight
draggedIndex > target && index in target until draggedIndex -> draggingItemHeight
else -> 0f
}
val animatedShift by animateFloatAsState(
targetValue = targetShift,
animationSpec = tween(200),
label = "GapTranslation"
)
val shiftTranslationY = if (draggedIndex != -1) animatedShift else targetShift
val translationY = when {
isDragging -> draggingDelta
isSettlingThis -> settlingDelta
else -> shiftTranslationY
}
BuilderItemRow(
item = entry.item,
dragKey = entry.id,
index = visualIndex,
colors = colors,
onUpdateQuestion = { text ->
entries[index] = entry.copy(item = CustomItem.Question(text))
},
onDelete = {
scope.launch {
val idx = entries.indexOfFirst { it.id == entry.id }
if (idx != -1) {
entries[idx] = entry.copy(isRemoving = true)
delay(280)
entries.removeAll { it.id == entry.id }
}
}
},
onDragStart = {
val info = listState.layoutInfo.visibleItemsInfo
.firstOrNull { it.key == entry.id } ?: return@BuilderItemRow
if (entry.isRemoving) return@BuilderItemRow
settlingItemId = null
settlingDelta = 0f
draggingItemId = entry.id
draggingItemHeight = info.size.toFloat() + spacingPx
draggingDelta = 0f
updateAutoScrollSpeed()
haptic.perform(HapticEvent.Expand)
},
onDrag = { delta -> onDragDelta(delta) },
onDragEnd = { onDragEnd() },
isDragging = isDragging || isSettlingThis,
isRemoving = entry.isRemoving,
translationY = translationY,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
if (entries.isEmpty()) {
item {
EmptySequence(colors, modifier = Modifier.padding(horizontal = 16.dp))
}
}
}
}
}
}
@Composable
private fun HeaderSection(
title: String,
onTitleChange: (String) -> Unit,
stepCount: Int,
colors: QuestionColors,
highlightColor: Color,
modifier: Modifier = Modifier
) {
val stepCountLabel = stringResource(Res.string.builder_step_count)
var isFocused by remember { mutableStateOf(false) }
val focusAlpha by animateFloatAsState(if (isFocused) 0.11f else 0.06f, label = "HeaderFocus")
val progress by animateFloatAsState(
targetValue = (stepCount / BuilderMaxSteps.toFloat()).coerceIn(0f, 1f),
animationSpec = tween(260),
label = "HeaderProgress"
)
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp))
.background(
Brush.verticalGradient(
listOf(
colors.text.copy(alpha = focusAlpha),
colors.background.copy(alpha = 0.2f)
)
)
)
.border(
1.dp,
if (isFocused) colors.glowColor.copy(alpha = 0.75f) else colors.text.copy(alpha = 0.16f),
RoundedCornerShape(28.dp)
)
.padding(start = 18.dp, end = 18.dp, top = 12.dp, bottom = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
BasicTextField(
value = title,
onValueChange = { if (it.length <= 30) onTitleChange(it) },
textStyle = TextStyle(
color = colors.text,
fontSize = 20.sp,
fontWeight = FontWeight.Black,
textAlign = TextAlign.Center,
letterSpacing = (-0.8).sp
),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
cursorBrush = SolidColor(highlightColor),
decorationBox = { inner ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
if (title.isEmpty()) {
Text(
stringResource(Res.string.builder_title_placeholder),
color = colors.text.copy(alpha = 0.15f),
fontSize = 21.sp,
fontWeight = FontWeight.Black,
textAlign = TextAlign.Center
)
}
inner()
}
}
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.weight(1f)
.height(3.dp)
.clip(CircleShape)
.background(colors.text.copy(alpha = 0.1f))
) {
Box(
modifier = Modifier
.fillMaxWidth(progress)
.height(3.dp)
.clip(CircleShape)
.background(highlightColor.copy(alpha = 0.72f))
)
}
Spacer(modifier = Modifier.width(10.dp))
Text(
text = formatString(stepCountLabel, stepCount, BuilderMaxSteps),
color = highlightColor,
fontSize = 11.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = 0.3.sp
)
}
}
}
@Composable
private fun AddStepSection(
entriesSize: Int,
onAdd: (CustomItem) -> Unit,
colors: QuestionColors,
isLandscape: Boolean
) {
Column {
val questionColor = tonedTypeColor(BuilderQuestionBase, colors)
val breathingColor = tonedTypeColor(BuilderBreathingBase, colors)
val awarenessColor = tonedTypeColor(BuilderAwarenessBase, colors)
val silenceColor = tonedTypeColor(BuilderSilenceBase, colors)
val libraryTitle = stringResource(Res.string.builder_library_title)
val librarySubtitle = stringResource(Res.string.builder_library_subtitle)
val questionTitle = stringResource(Res.string.builder_option_question_title)
val questionDesc = stringResource(Res.string.builder_option_question_desc)
val breathingTitle = stringResource(Res.string.builder_option_breathing_title)
val breathingDesc = stringResource(Res.string.builder_option_breathing_desc)
val awarenessTitle = stringResource(Res.string.builder_option_awareness_title)
val awarenessDesc = stringResource(Res.string.builder_option_awareness_desc)
val silenceTitle = stringResource(Res.string.builder_option_silence_title)
val silenceDesc = stringResource(Res.string.builder_option_silence_desc)
Text(
libraryTitle,
color = colors.text.copy(alpha = 0.86f),
fontWeight = FontWeight.Black,
fontSize = 12.sp,
letterSpacing = 2.1.sp,
modifier = Modifier.padding(horizontal = 24.dp)
)
Text(
librarySubtitle,
color = colors.text.copy(alpha = 0.5f),
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp)
)
if (isLandscape) {
LazyRow(
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
ExerciseOption(
title = questionTitle,
description = questionDesc,
icon = Icons.AutoMirrored.Filled.HelpOutline,
colors = colors,
typeColor = questionColor,
enabled = entriesSize < BuilderMaxSteps,
onClick = { onAdd(CustomItem.Question("")) }
)
}
item {
ExerciseOption(
title = breathingTitle,
description = breathingDesc,
icon = Icons.Default.Air,
colors = colors,
typeColor = breathingColor,
enabled = entriesSize < BuilderMaxSteps,
onClick = { onAdd(CustomItem.Breathing) }
)
}
item {
ExerciseOption(
title = awarenessTitle,
description = awarenessDesc,
icon = Icons.Default.CenterFocusStrong,
colors = colors,
typeColor = awarenessColor,
enabled = entriesSize < BuilderMaxSteps,
onClick = { onAdd(CustomItem.Awareness) }
)
}
item {
ExerciseOption(
title = silenceTitle,
description = silenceDesc,
icon = Icons.AutoMirrored.Filled.VolumeOff,
colors = colors,
typeColor = silenceColor,
enabled = entriesSize < BuilderMaxSteps,
onClick = { onAdd(CustomItem.Silence) }
)
}
}
} else {
LazyRow(
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
ExerciseOption(
title = questionTitle,
description = questionDesc,
icon = Icons.AutoMirrored.Filled.HelpOutline,
colors = colors,
typeColor = questionColor,
enabled = entriesSize < BuilderMaxSteps,
onClick = { onAdd(CustomItem.Question("")) }
)
}
item {
ExerciseOption(
title = breathingTitle,
description = breathingDesc,
icon = Icons.Default.Air,
colors = colors,
typeColor = breathingColor,
enabled = entriesSize < BuilderMaxSteps,
onClick = { onAdd(CustomItem.Breathing) }
)
}
item {
ExerciseOption(
title = awarenessTitle,
description = awarenessDesc,
icon = Icons.Default.CenterFocusStrong,
colors = colors,
typeColor = awarenessColor,
enabled = entriesSize < BuilderMaxSteps,
onClick = { onAdd(CustomItem.Awareness) }
)
}
item {
ExerciseOption(
title = silenceTitle,
description = silenceDesc,
icon = Icons.AutoMirrored.Filled.VolumeOff,
colors = colors,
typeColor = silenceColor,
enabled = entriesSize < BuilderMaxSteps,
onClick = { onAdd(CustomItem.Silence) }
)
}
}
}
if (entriesSize >= BuilderMaxSteps) {
// Toast is shown in addEntry to avoid shifting layout.
}
}
}
@Composable
private fun SequenceList(
listState: LazyListState,
entries: List<BuilderEntry>,
draggingItemId: Long?,
settlingItemId: Long?,
currentTargetIndex: Int?,
draggingDelta: Float,
settlingDelta: Float,
draggingItemHeight: Float,
colors: QuestionColors,
onUpdateQuestion: (Int, String) -> Unit,
onDelete: (BuilderEntry) -> Unit,
onDragStart: (BuilderEntry, LazyListItemInfo) -> Unit,
onDrag: (Float) -> Unit,
onDragEnd: () -> Unit
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 32.dp, top = 8.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item {
Text(
text = stringResource(Res.string.builder_drag_hint),
modifier = Modifier.padding(horizontal = 20.dp),
color = colors.text.copy(alpha = 0.48f),
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp,
letterSpacing = 0.5.sp
)
}
itemsIndexed(
items = entries,
key = { _, entry -> entry.id }
) { index, entry ->
val isDragging = draggingItemId == entry.id
val isSettlingThis = settlingItemId == entry.id
val draggedIndex =
draggingItemId?.let { id -> entries.indexOfFirst { it.id == id } } ?: -1
val target = currentTargetIndex ?: draggedIndex
val visualIndex = when {
isDragging -> target
draggedIndex != -1 && draggedIndex < target && index in (draggedIndex + 1)..target -> index - 1
draggedIndex != -1 && draggedIndex > target && index in target until draggedIndex -> index + 1
else -> index
}
val targetShift = when {
isDragging -> 0f
draggedIndex == -1 -> 0f
draggedIndex < target && index in (draggedIndex + 1)..target -> -draggingItemHeight
draggedIndex > target && index in target until draggedIndex -> draggingItemHeight
else -> 0f
}
val animatedShift by animateFloatAsState(
targetValue = targetShift,
animationSpec = tween(200),
label = "GapTranslation"
)
val shiftTranslationY = if (draggedIndex != -1) animatedShift else targetShift
val translationY = when {
isDragging -> draggingDelta
isSettlingThis -> settlingDelta
else -> shiftTranslationY
}
BuilderItemRow(
item = entry.item,
dragKey = entry.id,
index = visualIndex,
colors = colors,
onUpdateQuestion = { text -> onUpdateQuestion(index, text) },
onDelete = { onDelete(entry) },
onDragStart = {
val info = listState.layoutInfo.visibleItemsInfo
.firstOrNull { it.key == entry.id } ?: return@BuilderItemRow
onDragStart(entry, info)
},
onDrag = onDrag,
onDragEnd = onDragEnd,
isDragging = isDragging || isSettlingThis,
isRemoving = entry.isRemoving,
translationY = translationY,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
if (entries.isEmpty()) {
item {
EmptySequence(colors, modifier = Modifier.padding(horizontal = 16.dp))
}
}
}
}
@Composable
private fun ExerciseOption(
title: String,
description: String,
icon: ImageVector,
colors: QuestionColors,
typeColor: Color,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val alpha = if (enabled) 1f else 0.3f
val scale by animateFloatAsState(if (enabled) 1f else 0.95f, label = "OptionScale")
val shape = RoundedCornerShape(24.dp)
Column(
modifier = modifier
.width(115.dp)
.height(126.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.clip(shape)
.background(
Brush.verticalGradient(
listOf(
typeColor.copy(alpha = 0.3f),
colors.background.copy(alpha = 0.32f)
)
)
)
.border(
width = 1.dp,
brush = Brush.verticalGradient(
listOf(typeColor.copy(alpha = 0.9f), colors.text.copy(alpha = 0.14f))
),
shape = shape
)
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(typeColor.copy(alpha = 0.32f)),
contentAlignment = Alignment.Center
) {
Icon(
icon,
null,
tint = typeColor.copy(alpha = alpha),
modifier = Modifier.size(22.dp)
)
}
Spacer(modifier = Modifier.height(10.dp))
Text(
title,
color = colors.text.copy(alpha = alpha),
fontSize = 13.sp,
fontWeight = FontWeight.ExtraBold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(4.dp))
Text(
description,
color = colors.text.copy(alpha = 0.45f * alpha),
fontSize = 9.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
lineHeight = 11.sp,
maxLines = 2
)
}
}
@Composable
private fun BuilderItemRow(
item: CustomItem,
dragKey: Long,
index: Int,
colors: QuestionColors,
onUpdateQuestion: (String) -> Unit,
onDelete: () -> Unit,
onDragStart: () -> Unit,
onDrag: (Float) -> Unit,
onDragEnd: () -> Unit,
isDragging: Boolean,
isRemoving: Boolean,
translationY: Float,
modifier: Modifier = Modifier
) {
var appeared by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { appeared = true }
val animationAlpha by animateFloatAsState(
targetValue = if (appeared && !isRemoving) 1f else 0f,
animationSpec = tween(300),
label = "EntryExitAlpha"
)
val animationScale by animateFloatAsState(
targetValue = if (appeared && !isRemoving) 1f else 0.95f,
animationSpec = tween(300),
label = "EntryExitScale"
)
val scale by animateFloatAsState(if (isDragging) 1.01f else 1f, label = "DragScale")
val typeColor = baseTypeColor(item)
val cursorColor = colors.text
val backgroundColor = if (isDragging) colors.background else typeColor.copy(alpha = 0.26f)
val contentColor = colors.text
val dynamicBorderColor =
if (isDragging) typeColor.copy(alpha = 0.98f) else typeColor.copy(alpha = 0.75f)
val borderWidth = if (isDragging) 1.5.dp else 1.dp
val dragHandleAlpha = if (isDragging) 1f else 0.6f
Row(
modifier = modifier
.fillMaxWidth()
.zIndex(if (isDragging) 10f else 0f)
.graphicsLayer {
this.translationY = translationY
scaleX = scale * animationScale
scaleY = scale * animationScale
alpha = animationAlpha
}
.clip(RoundedCornerShape(24.dp))
.background(backgroundColor)
.border(
width = borderWidth,
color = dynamicBorderColor,
shape = RoundedCornerShape(24.dp)
)
.padding(start = 0.dp, end = 10.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Accent bar based on type
Box(
modifier = Modifier
.width(6.dp)
.height(48.dp)
.clip(RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp))
.background(typeColor)
)
Box(
modifier = Modifier
.padding(start = 8.dp, end = 6.dp)
.size(32.dp)
.clip(CircleShape)
.background(
if (isDragging) typeColor.copy(alpha = 0.16f) else typeColor.copy(
alpha = 0.12f
)
)
.pointerInput(dragKey) {
detectDragGestures(
onDragStart = { onDragStart() },
onDragEnd = { onDragEnd() },
onDragCancel = { onDragEnd() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount.y)
}
)
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.DragIndicator,
contentDescription = stringResource(Res.string.builder_drag_content_desc),
tint = if (isDragging) typeColor.copy(alpha = dragHandleAlpha) else typeColor.copy(
alpha = 0.9f * dragHandleAlpha
),
modifier = Modifier.size(18.dp).align(Alignment.Center)
)
}
Spacer(modifier = Modifier.width(6.dp))
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(
if (isDragging) typeColor.copy(alpha = 0.26f) else typeColor.copy(
alpha = 0.24f
)
),
contentAlignment = Alignment.Center
) {
if (isDragging) {
Text(
(index + 1).toString(),
color = typeColor,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
} else {
AnimatedContent(
targetState = (index + 1),
transitionSpec = {
fadeIn(animationSpec = tween(150)) togetherWith fadeOut(
animationSpec = tween(
150
)
)
},
label = "IndexAnim"
) { targetIndex ->
Text(
targetIndex.toString(),
color = typeColor,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
}
}
}
Spacer(modifier = Modifier.width(10.dp))
Icon(
imageVector = when (item) {
is CustomItem.Question -> Icons.AutoMirrored.Filled.HelpOutline
is CustomItem.Breathing -> Icons.Default.Air
is CustomItem.Awareness -> Icons.Default.CenterFocusStrong
is CustomItem.Silence -> Icons.AutoMirrored.Filled.VolumeOff
},
contentDescription = null,
tint = typeColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Box(modifier = Modifier.weight(1f)) {
when (item) {
is CustomItem.Question -> {
BasicTextField(
value = item.text,
onValueChange = onUpdateQuestion,
textStyle = TextStyle(
color = contentColor,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
),
modifier = Modifier.fillMaxWidth(),
cursorBrush = SolidColor(cursorColor),
decorationBox = { inner ->
if (item.text.isEmpty()) {
Text(
stringResource(Res.string.builder_question_placeholder),
color = contentColor.copy(0.45f),
fontSize = 16.sp
)
}
inner()
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
)
}
is CustomItem.Breathing -> {
Text(
stringResource(Res.string.builder_item_breathing),
color = contentColor,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
is CustomItem.Awareness -> {
Text(
stringResource(Res.string.builder_item_awareness),
color = contentColor,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
is CustomItem.Silence -> {
Text(
stringResource(Res.string.builder_item_silence),
color = contentColor,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
}
}
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.RemoveCircleOutline,
null,
tint = if (isDragging) MonologueDanger.copy(alpha = 0.9f) else MonologueDanger.copy(alpha = 0.6f)
)
}
}
}
@Composable
private fun EmptySequence(colors: QuestionColors, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(22.dp))
.background(
Brush.verticalGradient(
listOf(
colors.text.copy(alpha = 0.05f),
colors.background.copy(alpha = 0.28f)
)
)
)
.border(1.dp, colors.text.copy(alpha = 0.11f), RoundedCornerShape(22.dp))
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(contentAlignment = Alignment.Center) {
// Suggestive placeholder shapes
Box(
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.background(colors.glowColor.copy(alpha = 0.12f))
)
Icon(
Icons.Default.DashboardCustomize,
null,
tint = colors.glowColor.copy(alpha = 0.35f),
modifier = Modifier.size(30.dp)
)
}
Spacer(modifier = Modifier.height(14.dp))
Text(
stringResource(Res.string.builder_empty_title),
color = colors.text.copy(alpha = 0.82f),
fontSize = 14.sp,
fontWeight = FontWeight.ExtraBold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(4.dp))
Text(
stringResource(Res.string.builder_empty_body),
color = colors.text.copy(alpha = 0.55f),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
lineHeight = 16.sp
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment