Let the location field grow with its address (#273) (#278)

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 <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/278
This commit is contained in:
Jean-Luc Makiola
2026-09-07 17:32:27 +02:00
co-authored by makiolaj
parent 80bf96b7dc
commit 60f05a5c65
3 changed files with 87 additions and 28 deletions
@@ -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<String, String>?
/**
* 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(
@@ -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