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:
10
CHANGELOG.md
10
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user