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:
@@ -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
|
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]).
|
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
|
## [2.19.4] — 2026-09-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -1593,3 +1602,4 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
|
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
|
||||||
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
||||||
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
|
[#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.height
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
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.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -788,8 +791,9 @@ private fun EventEditContent(
|
|||||||
EditCard(
|
EditCard(
|
||||||
icon = Icons.Default.Place,
|
icon = Icons.Default.Place,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||||
|
iconAtTop = true,
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
InlineField(
|
InlineField(
|
||||||
value = form.location,
|
value = form.location,
|
||||||
onValueChange = viewModel::setLocation,
|
onValueChange = viewModel::setLocation,
|
||||||
@@ -797,32 +801,49 @@ private fun EventEditContent(
|
|||||||
// A location is as often a meeting URL as an address,
|
// A location is as often a meeting URL as an address,
|
||||||
// and "Zoom.us/j/123" reads wrong (#146).
|
// and "Zoom.us/j/123" reads wrong (#146).
|
||||||
capitalization = KeyboardCapitalization.None,
|
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
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
|
.animateContentSizeMotion()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
)
|
)
|
||||||
IconButton(
|
// The box is exactly one text line tall, so the button
|
||||||
onClick = {
|
// centres on the first line without making a single-line
|
||||||
runCatching {
|
// card taller than its text; requiredSize keeps the
|
||||||
pickContactAddress.launch(
|
// button's own 40dp ripple.
|
||||||
Intent(
|
Box(
|
||||||
Intent.ACTION_PICK,
|
modifier = Modifier.height(firstLineHeight()),
|
||||||
ContactsContract.CommonDataKinds.StructuredPostal
|
contentAlignment = Alignment.Center,
|
||||||
.CONTENT_URI,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
IconButton(
|
||||||
imageVector = Icons.Default.Contacts,
|
onClick = {
|
||||||
contentDescription = stringResource(
|
runCatching {
|
||||||
R.string.event_edit_location_from_contacts,
|
pickContactAddress.launch(
|
||||||
),
|
Intent(
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
Intent.ACTION_PICK,
|
||||||
modifier = Modifier.size(22.dp),
|
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 the formatted postal address from a contact-pick result URI. No
|
||||||
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
* 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
|
* The provider's formatted address is multi-line; collapse it to one comma-
|
||||||
* it sits cleanly in the single-line location field.
|
* separated line, which is what the location field holds.
|
||||||
*/
|
*/
|
||||||
private fun readContactAddress(context: Context, uri: Uri): String? =
|
private fun readContactAddress(context: Context, uri: Uri): String? =
|
||||||
context.contentResolver.query(
|
context.contentResolver.query(
|
||||||
@@ -2177,14 +2198,19 @@ private fun EditCard(
|
|||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
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(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = iconContentDescription,
|
contentDescription = iconContentDescription,
|
||||||
tint = iconTint,
|
tint = iconTint,
|
||||||
// 4dp mirrors InlineField's vertical padding, so a
|
|
||||||
// top-aligned icon (24dp) centres on the ~24sp first line.
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = if (iconAtTop) 4.dp else 0.dp)
|
.padding(top = iconOffset)
|
||||||
.size(24.dp),
|
.size(24.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
@@ -2241,6 +2267,7 @@ private fun InlineField(
|
|||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
||||||
|
imeAction: ImeAction = ImeAction.Default,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
@@ -2256,9 +2283,24 @@ private fun InlineField(
|
|||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
capitalization = capitalization,
|
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. */
|
/** One schedule row: label, then tappable date and (unless all-day) time. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScheduleRow(
|
private fun ScheduleRow(
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
private const val TAG = "EventEdit"
|
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
|
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||||
* want different reminder handling (#49), and differ in whether they own the
|
* 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.
|
// paste would introduce, so it never reaches the provider's TITLE column.
|
||||||
fun setTitle(value: String) =
|
fun setTitle(value: String) =
|
||||||
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
|
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 setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) {
|
fun setAllDay(value: Boolean) {
|
||||||
// Going all-day drops any pinned zone: the times become bare dates that
|
// Going all-day drops any pinned zone: the times become bare dates that
|
||||||
|
|||||||
Reference in New Issue
Block a user