From 60f05a5c65f88ab442a2741174e2c23df9b44d79 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 7 Sep 2026 17:32:27 +0200 Subject: [PATCH] Let the location field grow with its address (#273) (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The location on the edit screen was a single line, so a full postal address or a long meeting link scrolled sideways out of view while you typed it. It now wraps and the card grows to fit. - The field is multi-line and animates its height; `setLocation` joins line breaks with `", "` rather than deleting them, so a pasted address reads as `12 Main St, 10115 Berlin` — the same shape `readContactAddress` has always produced for a contact picked from the picker — instead of running together. - The pin and the contacts button are positioned off the resolved `titleMedium` line height instead of a hardcoded 32dp/4dp, so they stay level with the first line at large font scales too. `EditCard`'s `iconAtTop` branch was changed with it; otherwise the button would have scaled while the icon beside it did not. The values are identical to the old ones at font scale 1.0. - The field passes `ImeAction.Done`. Without it, a multi-line field with no IME action turns Enter into a newline key whose every newline is then folded away — a dead key that only nudged the caret. The last two points are beyond the issue itself; they came out of a review of the first, since making the field multi-line is what put a fixed-height button next to text that grows. Closes #273 Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/278 --- CHANGELOG.md | 10 ++ .../calendula/ui/edit/EventEditScreen.kt | 96 +++++++++++++------ .../calendula/ui/edit/EventEditViewModel.kt | 9 +- 3 files changed, 87 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d773ce2..2fe53c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 mid-word at large font sizes. The view switcher, the title and the agenda's range bar also line up with the grid underneath them ([#165]). +### Fixed +- **A long location no longer runs off the edge of its field.** The location on + the edit screen sat on one line, so a full postal address or a long meeting + link scrolled sideways out of sight as you typed it. It now wraps and the card + grows to fit, with the pin and the contacts button staying level with the first + line at any font size. A multi-line address you paste in is joined with commas + — the way one picked from your contacts always has been — instead of running + together into a single word ([#273]). + ## [2.19.4] — 2026-09-01 ### Fixed @@ -1593,3 +1602,4 @@ automatically, with zero telemetry and no internet permission. [#219]: https://codeberg.org/jlmakiola/calendula/issues/219 [#248]: https://codeberg.org/jlmakiola/calendula/issues/248 [#253]: https://codeberg.org/jlmakiola/calendula/issues/253 +[#273]: https://codeberg.org/jlmakiola/calendula/issues/273 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 4ec97f0..6b9336d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -90,6 +91,7 @@ import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.pluralStringResource @@ -101,6 +103,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel @@ -788,8 +791,9 @@ private fun EventEditContent( EditCard( icon = Icons.Default.Place, iconContentDescription = stringResource(R.string.event_detail_location), + iconAtTop = true, ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.Top) { InlineField( value = form.location, onValueChange = viewModel::setLocation, @@ -797,32 +801,49 @@ private fun EventEditContent( // A location is as often a meeting URL as an address, // and "Zoom.us/j/123" reads wrong (#146). capitalization = KeyboardCapitalization.None, + // Multi-line so a long address or meeting URL wraps and + // the card grows instead of scrolling off one line + // (#273). The field holds no newlines of its own, so + // the IME's action key closes the keyboard rather than + // inserting a break setLocation would only fold away. + singleLine = false, + imeAction = ImeAction.Done, modifier = Modifier .weight(1f) + .animateContentSizeMotion() .padding(vertical = 4.dp), ) - IconButton( - onClick = { - runCatching { - pickContactAddress.launch( - Intent( - Intent.ACTION_PICK, - ContactsContract.CommonDataKinds.StructuredPostal - .CONTENT_URI, - ), - ) - } - }, - modifier = Modifier.size(40.dp), + // The box is exactly one text line tall, so the button + // centres on the first line without making a single-line + // card taller than its text; requiredSize keeps the + // button's own 40dp ripple. + Box( + modifier = Modifier.height(firstLineHeight()), + contentAlignment = Alignment.Center, ) { - Icon( - imageVector = Icons.Default.Contacts, - contentDescription = stringResource( - R.string.event_edit_location_from_contacts, - ), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) + IconButton( + onClick = { + runCatching { + pickContactAddress.launch( + Intent( + Intent.ACTION_PICK, + ContactsContract.CommonDataKinds.StructuredPostal + .CONTENT_URI, + ), + ) + } + }, + modifier = Modifier.requiredSize(40.dp), + ) { + Icon( + imageVector = Icons.Default.Contacts, + contentDescription = stringResource( + R.string.event_edit_location_from_contacts, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } } } } @@ -1992,8 +2013,8 @@ private fun readContactEmail(context: Context, uri: Uri): Pair? /** * Read the formatted postal address from a contact-pick result URI. No * READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access. - * The provider's formatted address is multi-line; collapse it to one line so - * it sits cleanly in the single-line location field. + * The provider's formatted address is multi-line; collapse it to one comma- + * separated line, which is what the location field holds. */ private fun readContactAddress(context: Context, uri: Uri): String? = context.contentResolver.query( @@ -2177,14 +2198,19 @@ private fun EditCard( modifier = Modifier.padding(16.dp), verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically, ) { + // A top-aligned icon centres on the first text line, whatever + // that line measures at the user's font scale. + val iconOffset = if (iconAtTop) { + ((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp) + } else { + 0.dp + } Icon( imageVector = icon, contentDescription = iconContentDescription, tint = iconTint, - // 4dp mirrors InlineField's vertical padding, so a - // top-aligned icon (24dp) centres on the ~24sp first line. modifier = Modifier - .padding(top = if (iconAtTop) 4.dp else 0.dp) + .padding(top = iconOffset) .size(24.dp), ) Spacer(Modifier.width(16.dp)) @@ -2241,6 +2267,7 @@ private fun InlineField( enabled: Boolean = true, keyboardType: KeyboardType = KeyboardType.Text, capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences, + imeAction: ImeAction = ImeAction.Default, modifier: Modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), @@ -2256,9 +2283,24 @@ private fun InlineField( enabled = enabled, keyboardType = keyboardType, capitalization = capitalization, + imeAction = imeAction, ) } +/** + * Height of one [InlineField] line: the resolved [TextStyle.lineHeight] plus the + * field's 4dp vertical padding. In dp, so it tracks the user's font scale + * instead of assuming the 24sp line of a 1.0 scale (#165). + */ +@Composable +private fun firstLineHeight( + textStyle: TextStyle = MaterialTheme.typography.titleMedium, +): Dp { + val lineHeight = textStyle.lineHeight + if (!lineHeight.isSp) return 32.dp + return with(LocalDensity.current) { lineHeight.toDp() } + 8.dp +} + /** One schedule row: label, then tappable date and (unless all-day) time. */ @Composable private fun ScheduleRow( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 503db44..017383d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -59,6 +59,9 @@ import javax.inject.Inject private const val TAG = "EventEdit" +/** Any line break, with the whitespace around it, as one match. */ +private val LINE_BREAK = Regex("""[ \t]*\R[ \t]*""") + /** * Where a prefilled [EventEditViewModel.openImported] form came from. The sources * want different reminder handling (#49), and differ in whether they own the @@ -532,7 +535,11 @@ class EventEditViewModel @Inject constructor( // paste would introduce, so it never reaches the provider's TITLE column. fun setTitle(value: String) = update { it.copy(title = value.replace("\n", "").replace("\r", "")) } - fun setLocation(value: String) = update { it.copy(location = value) } + // The location wraps too (#273) but is likewise one logical line. A pasted + // multi-line address joins the way the contact picker's does (#146), so it + // never runs together into "12 Main St10115 Berlin". + fun setLocation(value: String) = + update { it.copy(location = value.replace(LINE_BREAK, ", ")) } fun setDescription(value: String) = update { it.copy(description = value) } fun setAllDay(value: Boolean) { // Going all-day drops any pinned zone: the times become bare dates that