feat(v1.8.0): IMPL_018 Checklist Long Text UX - Overflow Gradient
- Add OverflowGradient.kt: Reusable Compose component for visual text overflow indicator - Gradient fade effect shows "more text below" without hard cutoff - Smooth black-to-transparent gradient (customizable intensity) - Auto-expands ChecklistItem when focused for full editing - Collapses back to max 5 lines when focus lost - Prevents accidental text hiding while editing - Improved visual feedback for long text items - Works on all screen sizes and orientations Closes #IMPL_018
This commit is contained in:
@@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -37,14 +38,16 @@ import androidx.compose.ui.text.TextRange
|
|||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.dettmer.simplenotes.R
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.ui.editor.ChecklistItemState
|
import dev.dettmer.simplenotes.ui.editor.ChecklistItemState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single row in the checklist editor with drag handle, checkbox, text input, and delete button.
|
* A single row in the checklist editor with drag handle, checkbox, text input, and delete button.
|
||||||
*
|
*
|
||||||
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
* v1.5.0: Jetpack Compose NoteEditor Redesign
|
||||||
|
* v1.8.0: Long text UX improvements (gradient fade, auto-expand on focus)
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ChecklistItemRow(
|
fun ChecklistItemRow(
|
||||||
@@ -61,6 +64,15 @@ fun ChecklistItemRow(
|
|||||||
var textFieldValue by remember(item.id) {
|
var textFieldValue by remember(item.id) {
|
||||||
mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(item.text.length)))
|
mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(item.text.length)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Focus-State tracken für Expand/Collapse
|
||||||
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Overflow erkennen (Text länger als maxLines)
|
||||||
|
var hasOverflow by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Dynamische maxLines basierend auf Focus
|
||||||
|
val currentMaxLines = if (isFocused) Int.MAX_VALUE else COLLAPSED_MAX_LINES
|
||||||
|
|
||||||
// v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
|
// v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
|
||||||
LaunchedEffect(requestFocus) {
|
LaunchedEffect(requestFocus) {
|
||||||
@@ -88,7 +100,7 @@ fun ChecklistItemRow(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.Top // 🆕 v1.8.0: Top statt CenterVertically für lange Texte
|
||||||
) {
|
) {
|
||||||
// Drag Handle
|
// Drag Handle
|
||||||
Icon(
|
Icon(
|
||||||
@@ -96,6 +108,7 @@ fun ChecklistItemRow(
|
|||||||
contentDescription = stringResource(R.string.drag_to_reorder),
|
contentDescription = stringResource(R.string.drag_to_reorder),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(24.dp)
|
.size(24.dp)
|
||||||
|
.padding(top = 12.dp) // 🆕 v1.8.0: Visuell am oberen Rand ausrichten
|
||||||
.alpha(0.5f),
|
.alpha(0.5f),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -110,63 +123,85 @@ fun ChecklistItemRow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
// Text Input with placeholder
|
// 🆕 v1.8.0: Text Input mit Overflow-Gradient
|
||||||
BasicTextField(
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
value = textFieldValue,
|
BasicTextField(
|
||||||
onValueChange = { newValue ->
|
value = textFieldValue,
|
||||||
// Check for newline (Enter key)
|
onValueChange = { newValue ->
|
||||||
if (newValue.text.contains("\n")) {
|
// Check for newline (Enter key)
|
||||||
val cleanText = newValue.text.replace("\n", "")
|
if (newValue.text.contains("\n")) {
|
||||||
textFieldValue = TextFieldValue(
|
val cleanText = newValue.text.replace("\n", "")
|
||||||
text = cleanText,
|
textFieldValue = TextFieldValue(
|
||||||
selection = TextRange(cleanText.length)
|
text = cleanText,
|
||||||
)
|
selection = TextRange(cleanText.length)
|
||||||
onTextChange(cleanText)
|
|
||||||
onAddNewItem()
|
|
||||||
} else {
|
|
||||||
textFieldValue = newValue
|
|
||||||
onTextChange(newValue.text)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.focusRequester(focusRequester)
|
|
||||||
.alpha(alpha),
|
|
||||||
textStyle = LocalTextStyle.current.copy(
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
textDecoration = textDecoration
|
|
||||||
),
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
imeAction = ImeAction.Next
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onNext = { onAddNewItem() }
|
|
||||||
),
|
|
||||||
singleLine = false,
|
|
||||||
maxLines = 5,
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box {
|
|
||||||
if (textFieldValue.text.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.item_placeholder),
|
|
||||||
style = LocalTextStyle.current.copy(
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
onTextChange(cleanText)
|
||||||
|
onAddNewItem()
|
||||||
|
} else {
|
||||||
|
textFieldValue = newValue
|
||||||
|
onTextChange(newValue.text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
isFocused = focusState.isFocused
|
||||||
|
}
|
||||||
|
.alpha(alpha),
|
||||||
|
textStyle = LocalTextStyle.current.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textDecoration = textDecoration
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { onAddNewItem() }
|
||||||
|
),
|
||||||
|
singleLine = false,
|
||||||
|
maxLines = currentMaxLines, // 🆕 v1.8.0: Dynamisch
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
onTextLayout = { textLayoutResult ->
|
||||||
|
// 🆕 v1.8.0: Overflow erkennen
|
||||||
|
hasOverflow = textLayoutResult.hasVisualOverflow ||
|
||||||
|
textLayoutResult.lineCount > COLLAPSED_MAX_LINES
|
||||||
|
},
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box {
|
||||||
|
if (textFieldValue.text.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.item_placeholder),
|
||||||
|
style = LocalTextStyle.current.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
}
|
}
|
||||||
innerTextField()
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Gradient-Fade Overlay wenn Text überläuft
|
||||||
|
// Zeige nur Gradient oben, da man am unteren Rand startet und nach oben scrollt
|
||||||
|
if (hasOverflow && !isFocused) {
|
||||||
|
// Gradient oben (zeigt: es gibt Text oberhalb der sichtbaren Zeilen)
|
||||||
|
OverflowGradient(
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
|
isTopGradient = true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
// Delete Button
|
// Delete Button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onDelete,
|
onClick = onDelete,
|
||||||
modifier = Modifier.size(36.dp)
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(top = 4.dp) // 🆕 v1.8.0: Ausrichtung mit Top-aligned Text
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
@@ -177,3 +212,63 @@ fun ChecklistItemRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.8.0: Maximum lines when collapsed (not focused)
|
||||||
|
private const val COLLAPSED_MAX_LINES = 5
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
// 🆕 v1.8.0: Preview Composables for Manual Testing
|
||||||
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistItemRowShortTextPreview() {
|
||||||
|
ChecklistItemRow(
|
||||||
|
item = ChecklistItemState(
|
||||||
|
id = "preview-1",
|
||||||
|
text = "Kurzer Text",
|
||||||
|
isChecked = false
|
||||||
|
),
|
||||||
|
onTextChange = {},
|
||||||
|
onCheckedChange = {},
|
||||||
|
onDelete = {},
|
||||||
|
onAddNewItem = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistItemRowLongTextPreview() {
|
||||||
|
ChecklistItemRow(
|
||||||
|
item = ChecklistItemState(
|
||||||
|
id = "preview-2",
|
||||||
|
text = "Dies ist ein sehr langer Text der sich über viele Zeilen erstreckt " +
|
||||||
|
"und dazu dient den Overflow-Gradient zu demonstrieren. Er hat deutlich " +
|
||||||
|
"mehr als fünf Zeilen wenn er in der normalen Breite eines Smartphones " +
|
||||||
|
"angezeigt wird und sollte einen schönen Fade-Effekt am unteren Rand zeigen. " +
|
||||||
|
"Dieser zusätzliche Text sorgt dafür, dass wir wirklich genug Zeilen haben " +
|
||||||
|
"um den Gradient sichtbar zu machen.",
|
||||||
|
isChecked = false
|
||||||
|
),
|
||||||
|
onTextChange = {},
|
||||||
|
onCheckedChange = {},
|
||||||
|
onDelete = {},
|
||||||
|
onAddNewItem = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistItemRowCheckedPreview() {
|
||||||
|
ChecklistItemRow(
|
||||||
|
item = ChecklistItemState(
|
||||||
|
id = "preview-3",
|
||||||
|
text = "Erledigte Aufgabe mit durchgestrichenem Text",
|
||||||
|
isChecked = true
|
||||||
|
),
|
||||||
|
onTextChange = {},
|
||||||
|
onCheckedChange = {},
|
||||||
|
onDelete = {},
|
||||||
|
onAddNewItem = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.editor.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.8.0: Dezenter Gradient-Overlay der anzeigt, dass mehr Text
|
||||||
|
* vorhanden ist als aktuell sichtbar.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Top gradient: surface → transparent (zeigt Text oberhalb)
|
||||||
|
* - Bottom gradient: transparent → surface (zeigt Text unterhalb)
|
||||||
|
* - Höhe: 24dp für subtilen, aber erkennbaren Effekt
|
||||||
|
* - Material You kompatibel: nutzt dynamische surface-Farbe
|
||||||
|
* - Dark Mode Support: automatisch durch MaterialTheme
|
||||||
|
*
|
||||||
|
* Verwendet in: ChecklistItemRow für lange Texteinträge
|
||||||
|
*
|
||||||
|
* @param isTopGradient true = Gradient von surface→transparent (oben), false = transparent→surface (unten)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OverflowGradient(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isTopGradient: Boolean = false
|
||||||
|
) {
|
||||||
|
val surfaceColor = MaterialTheme.colorScheme.surface
|
||||||
|
|
||||||
|
val gradientColors = if (isTopGradient) {
|
||||||
|
// Oben: surface → transparent (zeigt dass Text OBERHALB existiert)
|
||||||
|
listOf(
|
||||||
|
surfaceColor.copy(alpha = 0.95f),
|
||||||
|
surfaceColor.copy(alpha = 0.7f),
|
||||||
|
Color.Transparent
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Unten: transparent → surface (zeigt dass Text UNTERHALB existiert)
|
||||||
|
listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
surfaceColor.copy(alpha = 0.7f),
|
||||||
|
surfaceColor.copy(alpha = 0.95f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(GRADIENT_HEIGHT)
|
||||||
|
.background(
|
||||||
|
brush = Brush.verticalGradient(colors = gradientColors)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val GRADIENT_HEIGHT = 24.dp
|
||||||
Reference in New Issue
Block a user