From 99d379071b782c685a0f38fc3c00874565c3fdfa Mon Sep 17 00:00:00 2001 From: jonasgaudian <43753916+jonasgaudian@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:41:49 +0100 Subject: [PATCH] implement manual visibility control for `DraggableActionPanel` via `isOpen` and `onDismiss` props, and add a "more" options button to `VocabularyCard` to trigger the panel. --- .../vocabulary/card/DraggableActionPanel.kt | 83 ++++++++++------- .../view/vocabulary/card/VocabularyCard.kt | 88 +++++++++++-------- 2 files changed, 104 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/DraggableActionPanel.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/DraggableActionPanel.kt index 69b3538..3c8d329 100644 --- a/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/DraggableActionPanel.kt +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/DraggableActionPanel.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.gestures.animateTo import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset @@ -36,8 +37,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -51,7 +52,8 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import eu.gaudian.translator.R import eu.gaudian.translator.view.composable.AppIcons -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlin.math.roundToInt private enum class DragState { Minimized, Extended } @@ -60,6 +62,8 @@ private enum class DragState { Minimized, Extended } @Composable internal fun DraggableActionPanel( modifier: Modifier = Modifier, + isOpen: Boolean, + onDismiss: () -> Unit, isEditing: Boolean, onEditClick: () -> Unit, onSaveClick: () -> Unit, @@ -71,7 +75,6 @@ internal fun DraggableActionPanel( showAnalyzeGrammarButton: Boolean, onAnalyzeGrammarClick: () -> Unit, ) { - val coroutineScope = rememberCoroutineScope() val density = LocalDensity.current val positionalThreshold = { totalDistance: Float -> totalDistance * 0.5f } @@ -92,14 +95,13 @@ internal fun DraggableActionPanel( } var panelWidth by remember { mutableFloatStateOf(0f) } - val minimizedWidth = with(density) { 64.dp.toPx() } val isExtended = state.targetValue == DragState.Extended LaunchedEffect(panelWidth) { if (panelWidth > 0) { val anchors = DraggableAnchors { DragState.Extended at 0f - DragState.Minimized at panelWidth - minimizedWidth + DragState.Minimized at panelWidth } if (state.anchors != anchors) { state.updateAnchors(anchors) @@ -107,6 +109,24 @@ internal fun DraggableActionPanel( } } + LaunchedEffect(isOpen) { + if (isOpen) { + state.animateTo(DragState.Extended) + } else { + state.animateTo(DragState.Minimized) + } + } + + LaunchedEffect(state) { + snapshotFlow { state.currentValue } + .distinctUntilChanged() + .collectLatest { + if (it == DragState.Minimized) { + onDismiss() + } + } + } + Card( shape = RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), @@ -148,39 +168,37 @@ internal fun DraggableActionPanel( verticalArrangement = Arrangement.Center ) { - val actionClickHandler: (() -> Unit) -> () -> Unit = { action -> - { - action() - coroutineScope.launch { - state.animateTo(DragState.Minimized) + val actionClickHandler: (() -> Unit) -> () -> Unit = { action -> + { + action() + onDismiss() } } - } - if (isEditing) { - ActionItem(icon = AppIcons.Check, label = stringResource(R.string.label_save), isExtended = isExtended, onClick = actionClickHandler(onSaveClick)) - ActionItem(icon = AppIcons.Close, stringResource(R.string.label_cancel), isExtended = isExtended, onClick = actionClickHandler(onCancelClick)) - } else { - ActionItem(icon = AppIcons.Edit, label = stringResource(R.string.edit), isExtended = isExtended, onClick = actionClickHandler(onEditClick)) - } - if (!isEditing) { - - if (showAnalyzeGrammarButton) { - ActionItem( - icon = AppIcons.AI, - label = stringResource(R.string.label_analyze_grammar), - isExtended = isExtended, - onClick = actionClickHandler(onAnalyzeGrammarClick) - ) + if (isEditing) { + ActionItem(icon = AppIcons.Check, label = stringResource(R.string.label_save), isExtended = isExtended, onClick = actionClickHandler(onSaveClick)) + ActionItem(icon = AppIcons.Close, stringResource(R.string.label_cancel), isExtended = isExtended, onClick = actionClickHandler(onCancelClick)) + } else { + ActionItem(icon = AppIcons.Edit, label = stringResource(R.string.edit), isExtended = isExtended, onClick = actionClickHandler(onEditClick)) } + if (!isEditing) { - ActionItem(icon = AppIcons.Category, label = stringResource(R.string.move_to_category), isExtended = isExtended, onClick = actionClickHandler(onMoveToCategoryClick)) - ActionItem(icon = AppIcons.Stages, label = stringResource(R.string.move_to_stage), isExtended = isExtended, onClick = actionClickHandler(onMoveToStageClick)) - ActionItem(icon = AppIcons.Statistics, label = stringResource(R.string.label_statistics), isExtended = isExtended, onClick = actionClickHandler(onStatisticsClick)) + if (showAnalyzeGrammarButton) { + ActionItem( + icon = AppIcons.AI, + label = stringResource(R.string.label_analyze_grammar), + isExtended = isExtended, + onClick = actionClickHandler(onAnalyzeGrammarClick) + ) + } + + ActionItem(icon = AppIcons.Category, label = stringResource(R.string.move_to_category), isExtended = isExtended, onClick = actionClickHandler(onMoveToCategoryClick)) + ActionItem(icon = AppIcons.Stages, label = stringResource(R.string.move_to_stage), isExtended = isExtended, onClick = actionClickHandler(onMoveToStageClick)) + ActionItem(icon = AppIcons.Statistics, label = stringResource(R.string.label_statistics), isExtended = isExtended, onClick = actionClickHandler(onStatisticsClick)) - ActionItem(icon = AppIcons.Delete, stringResource(R.string.label_delete), isExtended = isExtended, onClick = actionClickHandler(onDeleteClick)) - } + ActionItem(icon = AppIcons.Delete, stringResource(R.string.label_delete), isExtended = isExtended, onClick = actionClickHandler(onDeleteClick)) + } } } } @@ -220,6 +238,7 @@ private fun ActionItem( Icon(icon, contentDescription = label) } if (isExtended) { + Spacer(modifier = Modifier.width(8.dp)) Text(text = label, style = MaterialTheme.typography.bodyMedium) } } @@ -229,6 +248,8 @@ private fun ActionItem( @Composable fun DraggableActionPanelPreview() { DraggableActionPanel( + isOpen = true, + onDismiss = {}, isEditing = false, onEditClick = {}, onSaveClick = {}, diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/VocabularyCard.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/VocabularyCard.kt index 2c5a002..3a5e4d0 100644 --- a/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/VocabularyCard.kt +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/VocabularyCard.kt @@ -113,9 +113,9 @@ fun VocabularyCard( } catch (e: Exception) { StatusMessageService.triggerNonSuspend( StatusAction.ShowMessage( - text = e.toString(), - type = MessageDisplayType.ERROR, - timeoutInSeconds = 3 + text = e.toString(), + type = MessageDisplayType.ERROR, + timeoutInSeconds = 3 ) ) VocabularyFeatures() @@ -130,6 +130,7 @@ fun VocabularyCard( .collectAsState(initial = null) var isEditing by remember(item.id) { mutableStateOf(false) } + var showActionPanel by remember { mutableStateOf(false) } LaunchedEffect(key1 = item.features, key2 = isEditing) { if (!isEditing) { @@ -139,9 +140,9 @@ fun VocabularyCard( } catch (e: Exception) { StatusMessageService.triggerNonSuspend( StatusAction.ShowMessage( - text = e.toString(), - type = MessageDisplayType.ERROR, - timeoutInSeconds = 3 + text = e.toString(), + type = MessageDisplayType.ERROR, + timeoutInSeconds = 3 ) ) VocabularyFeatures() @@ -170,7 +171,7 @@ fun VocabularyCard( - val entryToNavigate by dictionaryViewModel.navigateToEntry.collectAsState() + val entryToNavigate by dictionaryViewModel.navigateToEntry.collectAsState() LaunchedEffect(key1 = entryToNavigate) { entryToNavigate?.let { entry -> // Set flag indicating navigation is from external source (not DictionaryResultScreen) @@ -237,13 +238,11 @@ fun VocabularyCard( Box( modifier = Modifier.fillMaxSize() ) { - val panelVisible = isFlipped || !exerciseMode - val panelMargin = if (panelVisible) 64.dp else 0.dp Card( modifier = Modifier .fillMaxSize() - .padding(start = 0.dp, top = 0.dp, bottom = 0.dp, end = panelMargin) + .padding(start = 0.dp, top = 0.dp, bottom = 0.dp, end = 0.dp) .graphicsLayer { this.rotationY = rotationY }, shape = RoundedCornerShape(24.dp), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), @@ -294,10 +293,25 @@ fun VocabularyCard( showBottomSheet = true } ) - HorizontalDivider( - modifier = Modifier.padding(horizontal = 4.dp), - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp) + ) { + HorizontalDivider( + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + if (!exerciseMode && !isFlipped) { + IconButton(onClick = { showActionPanel = true }) { + Icon( + imageVector = AppIcons.MoreVert, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } CardFace( modifier = backFaceModifier, @@ -336,6 +350,8 @@ fun VocabularyCard( modifier = Modifier .align(Alignment.CenterEnd) .height((IntrinsicSize.Min)), + isOpen = showActionPanel, + onDismiss = { showActionPanel = false }, isEditing = isEditing, onEditClick = { isEditing = true }, onSaveClick = { handleSave() }, @@ -424,7 +440,7 @@ fun VocabularyCardPreview() { @Composable private fun FrequencyPill(zipfFrequency: Float?) { if (zipfFrequency == null) return - + val semanticColors = MaterialTheme.semanticColors val (label, color) = when { zipfFrequency <= 3f -> Pair(stringResource(R.string.text_rare), semanticColors.wrongContainer) @@ -580,22 +596,22 @@ private fun CardFace( val showSpellingCorrection = !isEditing && isRevealed && (userSpellingAnswer != null) if(!isExerciseMode) { - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 0.dp) - ){ - val zipfFrequency = if(isFirst) vocabularyItem?.zipfFrequencyFirst else vocabularyItem?.zipfFrequencySecond + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 0.dp) + ){ + val zipfFrequency = if(isFirst) vocabularyItem?.zipfFrequencyFirst else vocabularyItem?.zipfFrequencySecond - FrequencyPill(zipfFrequency = zipfFrequency) - - GrammarPill( - wordDetails = wordDetails, - language = language, - isEditing = isEditing, - isRevealed = isRevealed, - onEditGrammarClick = onEditGrammarClick - ) - } + FrequencyPill(zipfFrequency = zipfFrequency) + + GrammarPill( + wordDetails = wordDetails, + language = language, + isEditing = isEditing, + isRevealed = isRevealed, + onEditGrammarClick = onEditGrammarClick + ) + } } Box(modifier = modifier.fillMaxSize()) { @@ -628,11 +644,11 @@ private fun CardFace( if (!isEditing && playable && isRevealed) { IconButton(onClick = { coroutineScope.launch { - settingsViewModel.speakingSpeed.value - val voice = settingsViewModel.getTtsVoiceForLanguage(language!!.code, language.region) - TextToSpeechHelper.speakOut(context, word, - language, voice) - } + settingsViewModel.speakingSpeed.value + val voice = settingsViewModel.getTtsVoiceForLanguage(language!!.code, language.region) + TextToSpeechHelper.speakOut(context, word, + language, voice) + } }) { Icon(AppIcons.TextToSpeech, stringResource(R.string.cd_text_to_speech)) } @@ -712,7 +728,7 @@ private fun CardFace( } // Info icon in bottom left - if (onMoreClick != null && !isEditing) { + if (onMoreClick != null && !isEditing && (!isExerciseMode)) { IconButton( onClick = onMoreClick, modifier = Modifier