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:
inventory69
2026-02-09 11:36:58 +01:00
parent bdfc0bf060
commit 3462f93f25
2 changed files with 206 additions and 49 deletions

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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.platform.LocalSoftwareKeyboardController
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.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.editor.ChecklistItemState
/**
* A single row in the checklist editor with drag handle, checkbox, text input, and delete button.
*
*
* v1.5.0: Jetpack Compose NoteEditor Redesign
* v1.8.0: Long text UX improvements (gradient fade, auto-expand on focus)
*/
@Composable
fun ChecklistItemRow(
@@ -61,6 +64,15 @@ fun ChecklistItemRow(
var textFieldValue by remember(item.id) {
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)
LaunchedEffect(requestFocus) {
@@ -88,7 +100,7 @@ fun ChecklistItemRow(
modifier = modifier
.fillMaxWidth()
.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
Icon(
@@ -96,6 +108,7 @@ fun ChecklistItemRow(
contentDescription = stringResource(R.string.drag_to_reorder),
modifier = Modifier
.size(24.dp)
.padding(top = 12.dp) // 🆕 v1.8.0: Visuell am oberen Rand ausrichten
.alpha(0.5f),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -110,63 +123,85 @@ fun ChecklistItemRow(
)
Spacer(modifier = Modifier.width(4.dp))
// Text Input with placeholder
BasicTextField(
value = textFieldValue,
onValueChange = { newValue ->
// Check for newline (Enter key)
if (newValue.text.contains("\n")) {
val cleanText = newValue.text.replace("\n", "")
textFieldValue = TextFieldValue(
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)
)
// 🆕 v1.8.0: Text Input mit Overflow-Gradient
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = textFieldValue,
onValueChange = { newValue ->
// Check for newline (Enter key)
if (newValue.text.contains("\n")) {
val cleanText = newValue.text.replace("\n", "")
textFieldValue = TextFieldValue(
text = cleanText,
selection = TextRange(cleanText.length)
)
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))
// Delete Button
IconButton(
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(
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 = {}
)
}

View File

@@ -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