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.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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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