Created
June 7, 2026 13:43
-
-
Save yasincidem/e96319adbc43d970ce2482bacc2335a3 to your computer and use it in GitHub Desktop.
.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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